From 0036e479684ccdc5206288481dfaf9e9bcde3d48 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 25 Aug 2025 14:17:12 +0545 Subject: [PATCH] feat(hooks): add git hooks management CLI --- CHANGELOG.md | 18 ++ README.md | 43 ++++ __init__.py | 2 +- devtools/__init__.py | 2 +- devtools/changelog/__init__.py | 4 +- devtools/changelog/cli.py | 123 +++++---- devtools/cli.py | 16 +- devtools/commitgen/__init__.py | 7 +- devtools/commitgen/cli.py | 148 +++++++---- devtools/commitgen/generator.py | 3 +- devtools/commitgen/git.py | 26 +- devtools/gitignore/__init__.py | 6 +- devtools/gitignore/cli.py | 35 ++- devtools/gitignore/detector.py | 50 ++-- devtools/gitignore/generator.py | 30 ++- devtools/hooks/__init__.py | 3 + devtools/hooks/cli.py | 424 +++++++++++++++++++++++++++++++ devtools/licensegen/__init__.py | 2 +- devtools/licensegen/cli.py | 142 +++++++---- devtools/licensegen/generator.py | 108 ++++---- devtools/shared/__init__.py | 2 +- devtools/shared/ai.py | 405 ++++++++++++++++------------- devtools/shared/config.py | 61 +++-- devtools/shared/git.py | 92 +++---- 24 files changed, 1221 insertions(+), 531 deletions(-) create mode 100644 devtools/hooks/__init__.py create mode 100644 devtools/hooks/cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2060f..58a9204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 0.1.1 + +## v0.1.1 + +✨ Added +- Added emoji option to CLI and config to control emoji prefixes + +📝 Documentation +- Recommended installation using pipx and uv + +## v0.1.1 + +✨ Added +- Added emoji option to CLI and config to control emoji prefixes + +📝 Documentation +- Recommended installation using pipx and uv + # Changelog ## v0.1.0 diff --git a/README.md b/README.md index 4528583..debc848 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,49 @@ If an invalid license type is provided, the tool will display a list of availabl --- +### Hooks Manager + +Manage Git hooks easily. Install lint/test hooks for `pre-commit` and `pre-push`. + +```bash +# Install both pre-commit and pre-push hooks in current repo +devtools hooks install + +# Install only pre-commit +devtools hooks install --pre-commit --no-pre-push + +# Install only pre-push +devtools hooks install --no-pre-commit --pre-push + +# Uninstall hooks +devtools hooks uninstall # both +devtools hooks uninstall --pre-commit --no-pre-push + +# Use your own script templates +devtools hooks install \ + --pre-commit-template ./scripts/hooks/my-pre-commit.sh \ + --pre-push-template ./scripts/hooks/my-pre-push.sh + +# List hooks present in the repository +devtools hooks list +``` + +The hooks auto-detect your ecosystem and run sensible defaults: + +- Node.js: `npm run lint --if-present` then `eslint` or `prettier --check`; tests via `npm test`/`pnpm test`/`yarn test` +- Python: `ruff .` or fallback to `black --check .`; tests via `pytest -q` +- Rust: `cargo fmt --check`; tests via `cargo test` +- Go: `gofmt -l` (fail if unformatted), `go vet`; tests via `go test ./...` +- Java: Gradle `check`/`test` or Maven `verify -DskipTests=true`/`test` +- If `.pre-commit-config.yaml` is present, `pre-commit run --all-files` is used for pre-commit, and `pre-commit run --hook-stage push --all-files` for pre-push. + +You can customize hooks by editing files in `.git/hooks/` after installation. +Alternatively, pass `--pre-commit-template` and/or `--pre-push-template` to install from your own script files. The files will be copied into `.git/hooks/` and made executable. + +Note: If a hook already exists, DevTools creates a timestamped backup in `.git/hooks/` with the `.bak` suffix before overwriting, e.g., `pre-commit.20250101123000.bak`. + +--- + ## Features - **Commit Message Generator:** diff --git a/__init__.py b/__init__.py index 16b3278..d3ee6c5 100644 --- a/__init__.py +++ b/__init__.py @@ -2,4 +2,4 @@ Commit message generator module """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/devtools/__init__.py b/devtools/__init__.py index 0a4e421..e280dab 100644 --- a/devtools/__init__.py +++ b/devtools/__init__.py @@ -2,4 +2,4 @@ DevTools - A collection of developer tools. """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/devtools/changelog/__init__.py b/devtools/changelog/__init__.py index f1ee095..e9bcafd 100644 --- a/devtools/changelog/__init__.py +++ b/devtools/changelog/__init__.py @@ -4,6 +4,4 @@ Changelog generation using AI. from .generator import ChangelogGenerator -__all__ = [ - "ChangelogGenerator" -] \ No newline at end of file +__all__ = ["ChangelogGenerator"] diff --git a/devtools/changelog/cli.py b/devtools/changelog/cli.py index bab901a..86ebca9 100644 --- a/devtools/changelog/cli.py +++ b/devtools/changelog/cli.py @@ -1,6 +1,7 @@ """ Changelog generation CLI. """ + import os import click from rich.console import Console @@ -11,28 +12,41 @@ from ..shared.config import Config console = Console() + @click.group() def cli(): """Generate changelogs.""" pass + @cli.command() -@click.option("--version", "-v", required=True, help="Version number for the changelog entry") +@click.option( + "--version", "-v", required=True, help="Version number for the changelog entry" +) @click.option("--from-tag", "-t", help="Generate changelog from this tag") @click.option("--days", "-d", type=int, help="Generate changelog from the last N days") -@click.option("--commits", "-n", type=int, help="Generate changelog from the last N commits") +@click.option( + "--commits", "-n", type=int, help="Generate changelog from the last N commits" +) @click.option("--output", "-o", default="CHANGELOG.md", help="Output file path") @click.option("--temperature", type=float, default=0.7, help="AI temperature (0.0-1.0)") -def generate(version: str, from_tag: str, days: int, commits: int, output: str, temperature: float): +def generate( + version: str, + from_tag: str, + days: int, + commits: int, + output: str, + temperature: float, +): """Generate a changelog from git history.""" try: # Get configuration config = Config() - + # Validate version format if not version.startswith("v"): version = f"v{version}" - + # Get git history if from_tag: # Get commits since tag @@ -46,16 +60,16 @@ def generate(version: str, from_tag: str, days: int, commits: int, output: str, else: # Get all commits cmd = "git log --pretty=format:%s" - + # Execute git command result = os.popen(cmd).read().strip() if not result: console.print("[yellow]No commits found in the specified range") return - + # Split into individual commits commit_messages = [msg.strip() for msg in result.split("\n") if msg.strip()] - + # Group commits by type groups = { "✨ Added": [], @@ -65,9 +79,9 @@ def generate(version: str, from_tag: str, days: int, commits: int, output: str, "📝 Documentation": [], "🔧 Maintenance": [], "🗑️ Removed": [], - "🔒 Security": [] + "🔒 Security": [], } - + # Map commit types to groups type_map = { "feat": "✨ Added", @@ -83,21 +97,21 @@ def generate(version: str, from_tag: str, days: int, commits: int, output: str, "refactor": "🔧 Maintenance", "remove": "🗑️ Removed", "delete": "🗑️ Removed", - "security": "🔒 Security" + "security": "🔒 Security", } - + # Categorize commits for msg in commit_messages: msg_lower = msg.lower() categorized = False - + # Check for type prefixes for type_key, group in type_map.items(): if msg_lower.startswith(type_key): groups[group].append(msg) categorized = True break - + # If not categorized, check for keywords if not categorized: for type_key, group in type_map.items(): @@ -107,10 +121,10 @@ def generate(version: str, from_tag: str, days: int, commits: int, output: str, else: # Default to maintenance if no match groups["🔧 Maintenance"].append(msg) - + # Generate changelog changelog = f"# Changelog\n\n## {version}\n\n" - + # Add each group for group, messages in groups.items(): if messages: @@ -118,18 +132,21 @@ def generate(version: str, from_tag: str, days: int, commits: int, output: str, for msg in messages: changelog += f"- {msg}\n" changelog += "\n" - + # Write to file with open(output, "w") as f: f.write(changelog) - + console.print(f"[green]✓[/green] Changelog generated successfully at: {output}") - + except Exception as e: console.print(f"[red]Error:[/red] {str(e)}") + @cli.command() -@click.option("--version", "-v", required=True, help="Version number for the changelog entry") +@click.option( + "--version", "-v", required=True, help="Version number for the changelog entry" +) @click.option("--output", "-o", default="CHANGELOG.md", help="Output file path") @click.option("--temperature", type=float, default=0.7, help="AI temperature (0.0-1.0)") def generate_interactive(version: str, output: str, temperature: float): @@ -137,11 +154,11 @@ def generate_interactive(version: str, output: str, temperature: float): try: # Get configuration config = Config() - + # Validate version format if not version.startswith("v"): version = f"v{version}" - + # Initialize groups groups = { "✨ Added": [], @@ -151,10 +168,12 @@ def generate_interactive(version: str, output: str, temperature: float): "📝 Documentation": [], "🔧 Maintenance": [], "🗑️ Removed": [], - "🔒 Security": [] + "🔒 Security": [], } - - console.print("\n[bold cyan]Enter your changes (press Enter twice to finish):[/bold cyan]") + + console.print( + "\n[bold cyan]Enter your changes (press Enter twice to finish):[/bold cyan]" + ) console.print("[dim]Start each line with an emoji to categorize it:[/dim]") console.print("✨ - Added") console.print("🔄 - Changed") @@ -165,12 +184,12 @@ def generate_interactive(version: str, output: str, temperature: float): console.print("🗑️ - Removed") console.print("🔒 - Security") console.print("\n[dim]Example: ✨ Added new feature X[/dim]\n") - + while True: line = click.prompt("", default="", show_default=False) if not line: break - + # Categorize based on emoji for group in groups: if line.startswith(group.split()[0]): @@ -179,10 +198,10 @@ def generate_interactive(version: str, output: str, temperature: float): else: # Default to maintenance if no emoji groups["🔧 Maintenance"].append(line) - + # Generate changelog changelog = f"# Changelog\n\n## {version}\n\n" - + # Add each group for group, messages in groups.items(): if messages: @@ -190,18 +209,21 @@ def generate_interactive(version: str, output: str, temperature: float): for msg in messages: changelog += f"- {msg}\n" changelog += "\n" - + # Write to file with open(output, "w") as f: f.write(changelog) - + console.print(f"[green]✓[/green] Changelog generated successfully at: {output}") - + except Exception as e: console.print(f"[red]Error:[/red] {str(e)}") + @cli.command() -@click.option("--version", "-v", required=True, help="Version number for the changelog entry") +@click.option( + "--version", "-v", required=True, help="Version number for the changelog entry" +) @click.option("--input", "-i", required=True, help="Input file with changes") @click.option("--output", "-o", default="CHANGELOG.md", help="Output file path") @click.option("--temperature", type=float, default=0.7, help="AI temperature (0.0-1.0)") @@ -210,22 +232,22 @@ def generate_from_file(version: str, input: str, output: str, temperature: float try: # Get configuration config = Config() - + # Validate version format if not version.startswith("v"): version = f"v{version}" - + # Read input file with open(input, "r") as f: changes = f.read().strip() - + if not changes: console.print("[yellow]No changes found in input file") return - + # Split into lines lines = [line.strip() for line in changes.split("\n") if line.strip()] - + # Initialize groups groups = { "✨ Added": [], @@ -235,21 +257,21 @@ def generate_from_file(version: str, input: str, output: str, temperature: float "📝 Documentation": [], "🔧 Maintenance": [], "🗑️ Removed": [], - "🔒 Security": [] + "🔒 Security": [], } - + # Categorize changes for line in lines: line_lower = line.lower() categorized = False - + # Check for emoji prefixes for group in groups: if line.startswith(group.split()[0]): groups[group].append(line) categorized = True break - + # If not categorized, check for keywords if not categorized: type_map = { @@ -265,9 +287,9 @@ def generate_from_file(version: str, input: str, output: str, temperature: float "refactor": "🔧 Maintenance", "remove": "🗑️ Removed", "delete": "🗑️ Removed", - "security": "🔒 Security" + "security": "🔒 Security", } - + for type_key, group in type_map.items(): if type_key in line_lower: groups[group].append(line) @@ -275,10 +297,10 @@ def generate_from_file(version: str, input: str, output: str, temperature: float else: # Default to maintenance if no match groups["🔧 Maintenance"].append(line) - + # Generate changelog changelog = f"# Changelog\n\n## {version}\n\n" - + # Add each group for group, messages in groups.items(): if messages: @@ -286,20 +308,21 @@ def generate_from_file(version: str, input: str, output: str, temperature: float for msg in messages: changelog += f"- {msg}\n" changelog += "\n" - + # Write to file with open(output, "w") as f: f.write(changelog) - + console.print(f"[green]✓[/green] Changelog generated successfully at: {output}") - + except Exception as e: console.print(f"[red]Error:[/red] {str(e)}") + # Add command aliases cli.add_command(generate, name="g") cli.add_command(generate_interactive, name="i") cli.add_command(generate_from_file, name="f") if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/devtools/cli.py b/devtools/cli.py index 2b2bdb4..273d324 100644 --- a/devtools/cli.py +++ b/devtools/cli.py @@ -1,6 +1,7 @@ """ Main CLI for devtools. """ + import click from rich.console import Console from rich.table import Table @@ -8,20 +9,24 @@ from .shared.config import Config from .gitignore.cli import cli as gitignore_cli from .commitgen.cli import cli as commit_cli from .licensegen.cli import license as license_cli +from .hooks.cli import cli as hooks_cli from .changelog.cli import cli as changelog_cli console = Console() + @click.group() def cli(): """DevTools - A collection of developer tools.""" pass + @cli.group() def config(): """Manage configuration.""" pass + @config.command() @click.argument("key") @click.argument("value") @@ -31,6 +36,7 @@ def set(key: str, value: str): config.set(key, value) console.print(f"[green]Set {key} to {value}") + @config.command() @click.argument("key", required=False) def show(key: str = None): @@ -46,12 +52,13 @@ def show(key: str = None): table = Table(title="Configuration") table.add_column("Key", style="cyan") table.add_column("Value", style="green") - + for k, v in config.get_all().items(): table.add_row(k, str(v)) - + console.print(table) + @config.command() @click.argument("key") def delete(key: str): @@ -63,6 +70,7 @@ def delete(key: str): else: console.print(f"[yellow]No value set for {key}") + @config.command() def clear(): """Clear all configuration values.""" @@ -71,11 +79,13 @@ def clear(): config.clear() console.print("[green]Cleared all configuration") + # Add subcommands cli.add_command(commit_cli, name="commit") cli.add_command(changelog_cli, name="changelog") cli.add_command(gitignore_cli, name="gitignore") cli.add_command(license_cli, name="license") +cli.add_command(hooks_cli, name="hooks") if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/devtools/commitgen/__init__.py b/devtools/commitgen/__init__.py index eca22ca..9cdc990 100644 --- a/devtools/commitgen/__init__.py +++ b/devtools/commitgen/__init__.py @@ -7,9 +7,4 @@ from .generator import CommitGenerator from .git import CommitGenGitService from ..changelog import ChangelogGenerator -__all__ = [ - "Config", - "CommitGenerator", - "CommitGenGitService", - "ChangelogGenerator" -] \ No newline at end of file +__all__ = ["Config", "CommitGenerator", "CommitGenGitService", "ChangelogGenerator"] diff --git a/devtools/commitgen/cli.py b/devtools/commitgen/cli.py index 48a002c..b6cf41a 100644 --- a/devtools/commitgen/cli.py +++ b/devtools/commitgen/cli.py @@ -1,6 +1,7 @@ """ Command-line interface for commitgen tool. """ + import click from rich.console import Console from rich.prompt import Prompt @@ -17,28 +18,53 @@ import os console = Console() + @click.group() def cli(): """Generate commit messages and changelogs using AI.""" pass + @cli.command() -@click.option('--files', '-f', multiple=True, help='Specific files to generate commit messages for') -@click.option('--repo', '-r', help='Repository path') -@click.option('--commit', '-c', is_flag=True, help='Automatically commit changes') -@click.option('--push', '-p', is_flag=True, help='Push changes after commit') -@click.option('--conventional/--no-conventional', default=True, help='Use conventional commit format') -@click.option('--no-stage', is_flag=True, help='Skip automatic staging of changes') -@click.option('--sign', is_flag=True, help='Sign commits with GPG') -@click.option('--temperature', type=float, help='AI temperature (0.0-1.0)') -@click.option('--emoji/--no-emoji', default=False, help='Include emoji prefixes in commit messages') -def generate(files: tuple, repo: str, commit: bool, push: bool, conventional: bool, no_stage: bool, sign: bool, temperature: float, emoji: bool): +@click.option( + "--files", + "-f", + multiple=True, + help="Specific files to generate commit messages for", +) +@click.option("--repo", "-r", help="Repository path") +@click.option("--commit", "-c", is_flag=True, help="Automatically commit changes") +@click.option("--push", "-p", is_flag=True, help="Push changes after commit") +@click.option( + "--conventional/--no-conventional", + default=True, + help="Use conventional commit format", +) +@click.option("--no-stage", is_flag=True, help="Skip automatic staging of changes") +@click.option("--sign", is_flag=True, help="Sign commits with GPG") +@click.option("--temperature", type=float, help="AI temperature (0.0-1.0)") +@click.option( + "--emoji/--no-emoji", + default=False, + help="Include emoji prefixes in commit messages", +) +def generate( + files: tuple, + repo: str, + commit: bool, + push: bool, + conventional: bool, + no_stage: bool, + sign: bool, + temperature: float, + emoji: bool, +): """Generate commit messages for staged changes""" try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console + console=console, ) as progress: # Initialize services config = Config() @@ -64,7 +90,9 @@ def generate(files: tuple, repo: str, commit: bool, push: bool, conventional: bo # Generate commit message task = progress.add_task("Generating commit message...", total=None) - commit_message = ai_service.generate_commit_message(staged_changes, temperature) + commit_message = ai_service.generate_commit_message( + staged_changes, temperature + ) progress.update(task, completed=True) # Show preview and confirm @@ -83,31 +111,46 @@ def generate(files: tuple, repo: str, commit: bool, push: bool, conventional: bo console.print("[green]Changes committed successfully![/green]") else: - console.print("[yellow]Use --commit to apply the commit message.[/yellow]") + console.print( + "[yellow]Use --commit to apply the commit message.[/yellow]" + ) except Exception as e: console.print(f"[red]Error: {str(e)}[/red]") raise click.Abort() + @cli.group() def changelog(): """Generate and manage changelogs""" pass -@changelog.command(name='generate') -@click.option('--version', '-v', required=True, help='Version number for the changelog entry') -@click.option('--from-tag', '-t', help='Generate changelog from this tag') -@click.option('--days', '-d', type=int, help='Generate changelog from the last N days') -@click.option('--commits', '-n', type=int, help='Generate changelog from the last N commits') -@click.option('--output', '-o', help='Output file path (default: CHANGELOG.md)') -@click.option('--temperature', type=float, help='AI temperature (0.0-1.0)') -def generate_log(version: str, from_tag: str, days: int, commits: int, output: str, temperature: float): + +@changelog.command(name="generate") +@click.option( + "--version", "-v", required=True, help="Version number for the changelog entry" +) +@click.option("--from-tag", "-t", help="Generate changelog from this tag") +@click.option("--days", "-d", type=int, help="Generate changelog from the last N days") +@click.option( + "--commits", "-n", type=int, help="Generate changelog from the last N commits" +) +@click.option("--output", "-o", help="Output file path (default: CHANGELOG.md)") +@click.option("--temperature", type=float, help="AI temperature (0.0-1.0)") +def generate_log( + version: str, + from_tag: str, + days: int, + commits: int, + output: str, + temperature: float, +): """Generate a changelog from git history""" try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console + console=console, ) as progress: # Initialize services config = Config() @@ -115,7 +158,7 @@ def generate_log(version: str, from_tag: str, days: int, commits: int, output: s changelog_gen = ChangelogGenerator(config, git_service) task = progress.add_task("Analyzing commit history...", total=None) - + # Determine which method to use for getting commits if from_tag: entries = changelog_gen.get_commits(from_tag) @@ -126,11 +169,13 @@ def generate_log(version: str, from_tag: str, days: int, commits: int, output: s else: # Default: get commits from the last 7 days entries = changelog_gen.get_commits_since_date(7) - + progress.update(task, completed=True) if not entries: - console.print("[yellow]No conventional commits found in the specified range.[/yellow]") + console.print( + "[yellow]No conventional commits found in the specified range.[/yellow]" + ) return task = progress.add_task("Generating changelog...", total=None) @@ -138,20 +183,24 @@ def generate_log(version: str, from_tag: str, days: int, commits: int, output: s changelog_gen.update_changelog_file(version, entries, output_path) progress.update(task, completed=True) - console.print(f"[green]Changelog generated successfully at {output_path}![/green]") + console.print( + f"[green]Changelog generated successfully at {output_path}![/green]" + ) except Exception as e: console.print(f"[red]Error: {str(e)}[/red]") raise click.Abort() + @cli.group() def config(): """Manage configuration""" pass + @config.command() -@click.option('--key', '-k', required=True, help='Configuration key') -@click.option('--value', '-v', required=True, help='Configuration value') +@click.option("--key", "-k", required=True, help="Configuration key") +@click.option("--value", "-v", required=True, help="Configuration value") def set(key: str, value: str): """Set configuration value""" try: @@ -162,62 +211,66 @@ def set(key: str, value: str): console.print(f"[red]Error: {str(e)}[/red]") raise click.Abort() + @config.command() def show(): """Show current configuration""" try: config = Config() - console.print(Panel( - "\n".join(f"{k}: {v}" for k, v in config.get_all().items()), - title="[blue]Current Configuration[/blue]", - border_style="blue" - )) + console.print( + Panel( + "\n".join(f"{k}: {v}" for k, v in config.get_all().items()), + title="[blue]Current Configuration[/blue]", + border_style="blue", + ) + ) except Exception as e: console.print(f"[red]Error: {str(e)}[/red]") raise click.Abort() + @cli.command() def status(): """Show the current state of the repository.""" try: # Initialize services git = GitService(os.getcwd()) - + # Get repository info repo_name = git.get_repo_name() current_branch = git.get_current_branch() - + # Get changes staged_files = git.get_staged_files() unstaged_files = git.get_unstaged_files() - + # Create status table table = Table(title=f"Repository Status: {repo_name} ({current_branch})") table.add_column("Status", style="cyan") table.add_column("Files", style="green") - + # Add staged changes if staged_files: staged_content = "\n".join(f" • {file}" for file in staged_files) table.add_row("Staged", staged_content) else: table.add_row("Staged", "No staged changes") - + # Add unstaged changes if unstaged_files: unstaged_content = "\n".join(f" • {file}" for file in unstaged_files) table.add_row("Unstaged", unstaged_content) else: table.add_row("Unstaged", "No unstaged changes") - + # Show status console.print("\n[bold]Repository Status:[/bold]") console.print(table) - + # Show diffs if there are changes if staged_files or unstaged_files: console.print("\n[bold]Changes:[/bold]") - + # Show staged diffs if staged_files: console.print("\n[cyan]Staged Changes:[/cyan]") @@ -225,7 +278,7 @@ def status(): diff = git.get_diff(file, staged=True) if diff: console.print(Syntax(diff, "diff", theme="monokai")) - + # Show unstaged diffs if unstaged_files: console.print("\n[yellow]Unstaged Changes:[/yellow]") @@ -233,15 +286,16 @@ def status(): diff = git.get_diff(file, staged=False) if diff: console.print(Syntax(diff, "diff", theme="monokai")) - + except Exception as e: console.print(f"[red]Error: {str(e)}[/red]") raise click.Abort() + # Add command aliases -cli.add_command(generate, name='g') -cli.add_command(config, name='c') -cli.add_command(changelog, name='log') +cli.add_command(generate, name="g") +cli.add_command(config, name="c") +cli.add_command(changelog, name="log") if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/devtools/commitgen/generator.py b/devtools/commitgen/generator.py index 91bc98c..d1dcffd 100644 --- a/devtools/commitgen/generator.py +++ b/devtools/commitgen/generator.py @@ -16,7 +16,8 @@ class CommitGenerator(AIService): super().__init__(config) # Read emoji preference from config (default: disabled) self.use_emoji: bool = bool( - str(self.config.get("emoji", "false")).strip().lower() in [ + str(self.config.get("emoji", "false")).strip().lower() + in [ "1", "true", "yes", diff --git a/devtools/commitgen/git.py b/devtools/commitgen/git.py index 992da31..8eae9c0 100644 --- a/devtools/commitgen/git.py +++ b/devtools/commitgen/git.py @@ -1,46 +1,52 @@ """ Git service for commit_gen tool. """ + from typing import List, Optional from ..shared.git import GitService from ..shared.config import Config import os + class CommitGenGitService(GitService): """Git service for commit_gen tool.""" - + def __init__(self, config: Config): """Initialize commit_gen git service.""" super().__init__(os.getcwd()) # Use current directory as repo path self.config = config - + def get_staged_changes(self, files: Optional[List[str]] = None) -> str: """Get staged changes as a diff. - + Args: files: Optional list of files to get changes for """ diffs = self.get_all_diffs(staged=True) if files: diffs = {file: diff for file, diff in diffs.items() if file in files} - return "\n\n".join(f"Changes in {file}:\n{diff}" for file, diff in diffs.items()) - + return "\n\n".join( + f"Changes in {file}:\n{diff}" for file, diff in diffs.items() + ) + def get_unstaged_changes(self) -> str: """Get unstaged changes as a diff.""" diffs = self.get_all_diffs(staged=False) - return "\n\n".join(f"Changes in {file}:\n{diff}" for file, diff in diffs.items()) - + return "\n\n".join( + f"Changes in {file}:\n{diff}" for file, diff in diffs.items() + ) + def commit_changes(self, message: str, sign: bool = False) -> None: """Commit changes with the given message.""" if not self.commit(message, sign=sign): raise Exception("Failed to commit changes") - + def push_changes(self) -> None: """Push changes to the current branch.""" if not self.push(): raise Exception("Failed to push changes") - + def get_recent_commits(self, limit: int = 10) -> List[str]: """Get recent commit messages.""" commits = self.get_commit_history(limit=limit) - return [commit["message"] for commit in commits] \ No newline at end of file + return [commit["message"] for commit in commits] diff --git a/devtools/gitignore/__init__.py b/devtools/gitignore/__init__.py index 0f12f6e..d6474af 100644 --- a/devtools/gitignore/__init__.py +++ b/devtools/gitignore/__init__.py @@ -6,8 +6,4 @@ from .cli import cli from .generator import GitignoreGenerator from .detector import ProjectDetector -__all__ = [ - "cli", - "GitignoreGenerator", - "ProjectDetector" -] \ No newline at end of file +__all__ = ["cli", "GitignoreGenerator", "ProjectDetector"] diff --git a/devtools/gitignore/cli.py b/devtools/gitignore/cli.py index ed1af09..8132f93 100644 --- a/devtools/gitignore/cli.py +++ b/devtools/gitignore/cli.py @@ -1,6 +1,7 @@ """ Gitignore generation CLI. """ + import os import click from rich.console import Console @@ -11,11 +12,13 @@ from .detector import ProjectDetector console = Console() + @click.group() def cli(): """Generate .gitignore files.""" pass + @cli.command() @click.option("--directory", "-d", default=".", help="Directory to scan") @click.option("--output", "-o", default=".gitignore", help="Output file path") @@ -24,33 +27,38 @@ def auto(directory: str, output: str): with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console + console=console, ) as progress: # Initialize services progress.add_task("Initializing...", total=None) detector = ProjectDetector(directory) generator = GitignoreGenerator() - + # Detect project types progress.add_task("Detecting project types...", total=None) detected_types = detector.detect_project_types() - + if not detected_types: - console.print("[yellow]No project types detected. Using generic .gitignore.") + console.print( + "[yellow]No project types detected. Using generic .gitignore." + ) else: - console.print(f"\n[green]Detected project types:[/] {', '.join(detected_types)}") - + console.print( + f"\n[green]Detected project types:[/] {', '.join(detected_types)}" + ) + # Check if output file exists if os.path.exists(output): if not Confirm.ask(f"\n[yellow]File {output} already exists. Overwrite?"): return - + # Generate .gitignore progress.add_task("Generating .gitignore...", total=None) generator.update_gitignore_file(detected_types, output) - + console.print(f"\n[green]Generated .gitignore at {output}") + @cli.command() @click.argument("types", nargs=-1, required=True) @click.option("--output", "-o", default=".gitignore", help="Output file path") @@ -59,26 +67,27 @@ def generate(types: tuple, output: str): with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console + console=console, ) as progress: # Initialize services progress.add_task("Initializing...", total=None) generator = GitignoreGenerator() - + # Check if output file exists if os.path.exists(output): if not Confirm.ask(f"\n[yellow]File {output} already exists. Overwrite?"): return - + # Generate .gitignore progress.add_task("Generating .gitignore...", total=None) generator.update_gitignore_file(list(types), output) - + console.print(f"\n[green]Generated .gitignore at {output}") + # Add command aliases cli.add_command(auto, name="a") cli.add_command(generate, name="g") if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/devtools/gitignore/detector.py b/devtools/gitignore/detector.py index 37a0273..37a2bb6 100644 --- a/devtools/gitignore/detector.py +++ b/devtools/gitignore/detector.py @@ -1,12 +1,14 @@ """ Project type detection for gitignore generation. """ + import os from typing import List, Dict, Set + class ProjectDetector: """Detect project types based on file patterns.""" - + # File patterns for different project types PROJECT_PATTERNS = { # Languages @@ -22,7 +24,6 @@ class ProjectDetector: "csharp": ["*.cs", "*.csproj", "*.sln"], "swift": ["*.swift", "*.xcodeproj"], "objective-c": ["*.m", "*.h", "*.mm"], - # Frameworks "django": ["manage.py", "wsgi.py", "asgi.py"], "flask": ["app.py", "flask_app.py"], @@ -36,7 +37,6 @@ class ProjectDetector: "express": ["app.js", "server.js"], "next": ["next.config.js"], "nuxt": ["nuxt.config.js"], - # Build Tools "maven": ["pom.xml"], "gradle": ["build.gradle", "settings.gradle"], @@ -46,7 +46,6 @@ class ProjectDetector: "poetry": ["pyproject.toml"], "cargo": ["Cargo.toml"], "composer": ["composer.json"], - # IDEs and Editors "vscode": [".vscode/settings.json"], "intellij": [".idea/workspace.xml"], @@ -55,107 +54,100 @@ class ProjectDetector: "emacs": [".emacs", ".emacs.d/"], "sublime": ["*.sublime-project", "*.sublime-workspace"], "atom": [".atom/"], - # Operating Systems "windows": ["*.exe", "*.dll", "*.sys"], "macos": [".DS_Store", "*.app"], "linux": ["*.so", "*.o"], - # Testing Frameworks "jest": ["jest.config.js"], "pytest": ["pytest.ini", "conftest.py"], "junit": ["junit.xml"], "mocha": ["mocha.opts"], - # Documentation "sphinx": ["docs/conf.py"], "mkdocs": ["mkdocs.yml"], "docusaurus": ["docusaurus.config.js"], - # Databases "postgresql": ["*.sql", "*.dump"], "mysql": ["*.sql", "*.dump"], "mongodb": ["*.bson"], "sqlite": ["*.db", "*.sqlite"], - # Cloud Platforms "aws": [".aws/", "aws.json"], "azure": ["azure.yaml", "azure.json"], "gcp": ["gcloud.json"], - # Containerization "docker": ["Dockerfile", "docker-compose.yml"], "kubernetes": ["k8s/", "*.yaml"], - # CI/CD "github-actions": [".github/workflows/"], "gitlab-ci": [".gitlab-ci.yml"], "jenkins": ["Jenkinsfile"], "travis": [".travis.yml"], "circleci": [".circleci/config.yml"], - # Static Site Generators "jekyll": ["_config.yml"], "hugo": ["config.toml", "config.yaml"], "gatsby": ["gatsby-config.js"], - # Game Development "unity": ["*.unity", "*.unityproj"], "unreal": ["*.uproject"], - # Machine Learning "tensorflow": ["*.pb", "*.h5"], "pytorch": ["*.pt", "*.pth"], - "scikit-learn": ["*.joblib"] + "scikit-learn": ["*.joblib"], } - + def __init__(self, directory: str = "."): """Initialize project detector. - + Args: directory: Directory to scan """ self.directory = directory - + def detect_project_types(self) -> List[str]: """Detect project types in the directory. - + Returns: List of detected project types """ detected_types = set() - + # Scan directory for files for root, _, files in os.walk(self.directory): for file in files: file_path = os.path.join(root, file) rel_path = os.path.relpath(file_path, self.directory) - + # Check each project type's patterns for type_, patterns in self.PROJECT_PATTERNS.items(): - if any(self._matches_pattern(rel_path, pattern) for pattern in patterns): + if any( + self._matches_pattern(rel_path, pattern) for pattern in patterns + ): detected_types.add(type_) - + return sorted(list(detected_types)) - + def _matches_pattern(self, path: str, pattern: str) -> bool: """Check if a path matches a pattern. - + Args: path: Path to check pattern: Pattern to match - + Returns: True if path matches pattern """ # Handle directory patterns if pattern.endswith("/"): return path.startswith(pattern[:-1]) - + # Handle wildcard patterns if "*" in pattern: import fnmatch + return fnmatch.fnmatch(path, pattern) - + # Handle exact matches - return path == pattern \ No newline at end of file + return path == pattern diff --git a/devtools/gitignore/generator.py b/devtools/gitignore/generator.py index 7098951..bc7a2ae 100644 --- a/devtools/gitignore/generator.py +++ b/devtools/gitignore/generator.py @@ -1,47 +1,49 @@ """ Gitignore generator using gitignore.io API. """ + import os import requests from typing import List, Dict + class GitignoreGenerator: """Gitignore generator using gitignore.io API.""" - + def __init__(self): """Initialize gitignore generator.""" self.api_url = "https://www.toptal.com/developers/gitignore/api" - + def generate_gitignore(self, types: List[str]) -> str: """Generate .gitignore content for specified types. - + Args: types: List of project types - + Returns: Generated .gitignore content """ if not types: return self._get_generic_gitignore() - + try: # Get gitignore content from API response = requests.get(f"{self.api_url}/{','.join(types)}") response.raise_for_status() - + # Add header header = "# Generated by devtools gitignore generator\n" header += f"# Types: {', '.join(types)}\n\n" - + return header + response.text - + except requests.RequestException: # Fallback to generic gitignore if API fails return self._get_generic_gitignore() - + def _get_generic_gitignore(self) -> str: """Get a generic .gitignore template. - + Returns: Generic .gitignore content """ @@ -91,16 +93,16 @@ logs/ *.temp *.bak """ - + def update_gitignore_file(self, types: List[str], output_file: str) -> None: """Generate and update a .gitignore file. - + Args: types: List of project types output_file: Path to the .gitignore file """ content = self.generate_gitignore(types) - + # Write to file with open(output_file, "w") as f: - f.write(content) \ No newline at end of file + f.write(content) diff --git a/devtools/hooks/__init__.py b/devtools/hooks/__init__.py new file mode 100644 index 0000000..f6e98a7 --- /dev/null +++ b/devtools/hooks/__init__.py @@ -0,0 +1,3 @@ +""" +Git hooks manager package. +""" diff --git a/devtools/hooks/cli.py b/devtools/hooks/cli.py new file mode 100644 index 0000000..ab54863 --- /dev/null +++ b/devtools/hooks/cli.py @@ -0,0 +1,424 @@ +""" +Command-line interface for Git hooks management. +""" + +import os +import stat +from pathlib import Path +from datetime import datetime +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +console = Console() + +HOOK_TEMPLATES = { + "pre-commit": """#!/usr/bin/env bash +set -euo pipefail + +echo "Running pre-commit checks..." + +# If pre-commit is configured, defer to it +if command -v pre-commit >/dev/null 2>&1 && [ -f ".pre-commit-config.yaml" ]; then + pre-commit run --all-files + exit 0 +fi + +run_lint() { + # Node.js/TypeScript + if [ -f "package.json" ]; then + if command -v npm >/dev/null 2>&1; then + npm run -s lint --if-present || true + if command -v npx >/dev/null 2>&1; then + npx --yes eslint . || npx --yes prettier --check . || true + fi + return 0 + fi + if command -v pnpm >/dev/null 2>&1; then + pnpm -s lint || true + pnpm dlx eslint . || pnpm dlx prettier --check . || true + return 0 + fi + if command -v yarn >/dev/null 2>&1; then + yarn -s lint || true + npx --yes eslint . || npx --yes prettier --check . || true + return 0 + fi + fi + + # Python + if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then + if command -v ruff >/dev/null 2>&1; then + ruff . + return 0 + elif command -v black >/dev/null 2>&1; then + echo "ruff not found; running black --check instead" + black --check . + return 0 + fi + fi + + # Rust + if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then + cargo fmt --all -- --check + return 0 + fi + + # Go + if [ -f "go.mod" ]; then + if command -v golangci-lint >/dev/null 2>&1; then + golangci-lint run + return 0 + fi + if command -v go >/dev/null 2>&1; then + fmt_out=$(gofmt -l .) + if [ -n "$fmt_out" ]; then + echo "Files not formatted (gofmt):" + echo "$fmt_out" + exit 1 + fi + go vet ./... + return 0 + fi + fi + + # Java (best-effort) + if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then + if [ -x "./gradlew" ]; then + ./gradlew -q check || true + return 0 + fi + if command -v mvn >/dev/null 2>&1; then + mvn -q -DskipTests=true verify || true + return 0 + fi + fi + + echo "No recognized linter/formatter found; skipping lint/format checks" +} + +run_tests() { + # Node.js/TypeScript + if [ -f "package.json" ]; then + if command -v npm >/dev/null 2>&1; then + npm test -s --if-present || true + return 0 + fi + if command -v pnpm >/dev/null 2>&1; then + pnpm -s test || true + return 0 + fi + if command -v yarn >/dev/null 2>&1; then + yarn -s test || true + return 0 + fi + fi + + # Python + if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then + if command -v pytest >/dev/null 2>&1; then + pytest -q + return 0 + fi + fi + + # Rust + if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then + cargo test -q + return 0 + fi + + # Go + if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then + go test ./... + return 0 + fi + + # Java + if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then + if [ -x "./gradlew" ]; then + ./gradlew -q test || true + return 0 + fi + if command -v mvn >/dev/null 2>&1; then + mvn -q test || true + return 0 + fi + fi + + echo "No recognized test runner found; skipping tests" +} + +run_lint +run_tests + +""", + "pre-push": """#!/usr/bin/env bash +set -euo pipefail + +echo "Running pre-push checks..." + +# If pre-commit exists we can reuse tests configuration from it +if command -v pre-commit >/dev/null 2>&1 && [ -f ".pre-commit-config.yaml" ]; then + pre-commit run --hook-stage push --all-files || true +fi + +# Prefer ecosystem-specific tests +if [ -f "package.json" ]; then + if command -v npm >/dev/null 2>&1; then + npm test -s --if-present || true + exit 0 + fi + if command -v pnpm >/dev/null 2>&1; then + pnpm -s test || true + exit 0 + fi + if command -v yarn >/dev/null 2>&1; then + yarn -s test || true + exit 0 + fi +fi + +if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then + cargo test -q + exit 0 +fi + +if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then + go test ./... + exit 0 +fi + +if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then + if command -v pytest >/dev/null 2>&1; then + pytest -q + exit 0 + fi +fi + +if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then + if [ -x "./gradlew" ]; then + ./gradlew -q test || true + exit 0 + fi + if command -v mvn >/dev/null 2>&1; then + mvn -q test || true + exit 0 + fi +fi + +echo "No recognized test runner found; skipping tests" + +""", +} + + +def _write_hook(repo_root: Path, name: str, content: str) -> Path: + hooks_dir = repo_root / ".git" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + hook_path = hooks_dir / name + # Backup existing hook if present + if hook_path.exists(): + ts = datetime.now().strftime("%Y%m%d%H%M%S") + backup_path = hooks_dir / f"{name}.{ts}.bak" + backup_path.write_text(hook_path.read_text()) + hook_path.write_text(content) + hook_path.chmod( + hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + return hook_path + + +@click.group() +def cli(): + """Manage Git hooks (pre-commit, pre-push).""" + pass + + +@cli.command() +@click.option( + "--repo", + "repo", + type=click.Path(exists=True, file_okay=False), + default=os.getcwd(), + help="Repository path", +) +@click.option( + "--pre-commit/--no-pre-commit", default=True, help="Install pre-commit hook" +) +@click.option("--pre-push/--no-pre-push", default=True, help="Install pre-push hook") +@click.option( + "--pre-commit-template", + type=click.Path(exists=True, dir_okay=False), + help="Path to custom pre-commit script template", +) +@click.option( + "--pre-push-template", + type=click.Path(exists=True, dir_okay=False), + help="Path to custom pre-push script template", +) +def install( + repo: str, + pre_commit: bool, + pre_push: bool, + pre_commit_template: str | None, + pre_push_template: str | None, +): + """Install default hooks (lint/test).""" + try: + repo_root = Path(repo) + if not (repo_root / ".git").exists(): + console.print("[red]Not a git repository[/red]") + raise click.Abort() + + installed = [] + if pre_commit: + content = None + if pre_commit_template: + content = Path(pre_commit_template).read_text() + else: + content = HOOK_TEMPLATES["pre-commit"] + path = _write_hook(repo_root, "pre-commit", content) + installed.append(path.name) + if pre_push: + content = None + if pre_push_template: + content = Path(pre_push_template).read_text() + else: + content = HOOK_TEMPLATES["pre-push"] + path = _write_hook(repo_root, "pre-push", content) + installed.append(path.name) + + if installed: + console.print( + Panel( + f"Installed hooks: {', '.join(installed)}", + title="Git Hooks", + border_style="green", + ) + ) + else: + console.print("[yellow]No hooks selected.[/yellow]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise click.Abort() + + +@cli.command() +@click.option( + "--repo", + "repo", + type=click.Path(exists=True, file_okay=False), + default=os.getcwd(), + help="Repository path", +) +@click.option( + "--pre-commit/--no-pre-commit", default=True, help="Remove pre-commit hook" +) +@click.option("--pre-push/--no-pre-push", default=True, help="Remove pre-push hook") +def uninstall(repo: str, pre_commit: bool, pre_push: bool): + """Remove installed hooks.""" + try: + repo_root = Path(repo) + if not (repo_root / ".git").exists(): + console.print("[red]Not a git repository[/red]") + raise click.Abort() + + removed = [] + for name, selected in [("pre-commit", pre_commit), ("pre-push", pre_push)]: + if not selected: + continue + hook_path = repo_root / ".git" / "hooks" / name + if hook_path.exists(): + hook_path.unlink() + removed.append(name) + + if removed: + console.print( + Panel( + f"Removed hooks: {', '.join(removed)}", + title="Git Hooks", + border_style="yellow", + ) + ) + else: + console.print("[yellow]No hooks removed.") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise click.Abort() + + +@cli.command(name="list") +@click.option( + "--repo", + "repo", + type=click.Path(exists=True, file_okay=False), + default=os.getcwd(), + help="Repository path", +) +def list_hooks(repo: str): + """List hooks present in the repository.""" + try: + repo_root = Path(repo) + hooks_dir = repo_root / ".git" / "hooks" + if not hooks_dir.exists(): + console.print( + "[yellow]No .git/hooks directory found (not a git repo?).[/yellow]" + ) + return + + known = [ + "applypatch-msg", + "pre-applypatch", + "post-applypatch", + "pre-commit", + "pre-merge-commit", + "prepare-commit-msg", + "commit-msg", + "post-commit", + "pre-rebase", + "post-checkout", + "post-merge", + "pre-push", + "pre-receive", + "update", + "post-receive", + "post-update", + "reference-transaction", + "push-to-checkout", + "pre-auto-gc", + "post-rewrite", + "sendemail-validate", + "fsmonitor-watchman", + "p4-pre-submit", + ] + + table = Table(title="Git Hooks") + table.add_column("Hook", style="cyan") + table.add_column("Status", style="green") + table.add_column("Executable", style="magenta") + + found_any = False + for name in known: + p = hooks_dir / name + if p.exists(): + found_any = True + is_exec = os.access(p, os.X_OK) + table.add_row(name, "present", "yes" if is_exec else "no") + + # Include unknown custom hooks present + for p in sorted(hooks_dir.iterdir()): + if p.is_file() and p.name not in known: + found_any = True + is_exec = os.access(p, os.X_OK) + table.add_row(p.name, "custom", "yes" if is_exec else "no") + + if not found_any: + console.print("[yellow]No hooks found in .git/hooks.[/yellow]") + return + + console.print(table) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise click.Abort() diff --git a/devtools/licensegen/__init__.py b/devtools/licensegen/__init__.py index fe99d8a..e762dd5 100644 --- a/devtools/licensegen/__init__.py +++ b/devtools/licensegen/__init__.py @@ -2,4 +2,4 @@ from .generator import generate_license -__all__ = ["generate_license"] \ No newline at end of file +__all__ = ["generate_license"] diff --git a/devtools/licensegen/cli.py b/devtools/licensegen/cli.py index 87bfe5b..683811b 100644 --- a/devtools/licensegen/cli.py +++ b/devtools/licensegen/cli.py @@ -12,11 +12,13 @@ import re console = Console() + @click.group() def license(): """License generation commands.""" pass + @license.command() @click.option("--type", "-t", help="Type of license to generate") @click.option("--author", "-a", help="Name of the author/copyright holder") @@ -29,65 +31,71 @@ def generate(type: str, author: str, year: int, output: str, multi: bool): # If no author specified, prompt for it if not author: author = click.prompt("Enter author name") - + # Fetch available licenses licenses = license_generator.fetch_available_licenses() if not licenses: console.print("[yellow]No licenses available") return - + # Create selection table table = Table(show_header=True, header_style="bold magenta") table.add_column("No.", style="dim", width=4) table.add_column("License Key", style="dim", width=15) table.add_column("Name", width=30) table.add_column("Permissions", width=40) - + # Add rows with numbers for i, license in enumerate(licenses, 1): table.add_row( str(i), license["key"], license["name"], - format_permissions(license.get("permissions", [])) + format_permissions(license.get("permissions", [])), ) - + # Display table console.print("\nSelect license(s) to generate:") console.print(table) - + if multi: # Get user selection for multiple licenses while True: try: - selection = click.prompt("Enter license numbers (e.g., 1,3,5)").split(",") + selection = click.prompt( + "Enter license numbers (e.g., 1,3,5)" + ).split(",") selected_indices = [int(s.strip()) for s in selection if s.strip()] - + # Validate selection if not selected_indices: console.print("[yellow]No licenses selected") continue - + if any(i < 1 or i > len(licenses) for i in selected_indices): - console.print("[red]Invalid selection. Please enter valid numbers.") + console.print( + "[red]Invalid selection. Please enter valid numbers." + ) continue - + # Get selected licenses - selected_licenses = [licenses[i-1] for i in selected_indices] - + selected_licenses = [licenses[i - 1] for i in selected_indices] + # Show confirmation console.print("\nSelected licenses:") for license in selected_licenses: console.print(f"- {license['name']} ({license['key']})") - + if Confirm.ask("\nGenerate these licenses?"): break else: console.print("\nPlease select licenses again:") continue - + except ValueError: - console.print("[red]Invalid input. Please enter numbers separated by commas.") + console.print( + "[red]Invalid input. Please enter numbers separated by commas." + ) continue else: # Get user selection for single license @@ -95,29 +103,33 @@ def generate(type: str, author: str, year: int, output: str, multi: bool): try: selection = click.prompt("Enter license number") selected_index = int(selection.strip()) - + # Validate selection if selected_index < 1 or selected_index > len(licenses): - console.print("[red]Invalid selection. Please enter a valid number.") + console.print( + "[red]Invalid selection. Please enter a valid number." + ) continue - + # Get selected license selected_license = licenses[selected_index - 1] - + # Show confirmation - console.print(f"\nSelected license: {selected_license['name']} ({selected_license['key']})") - + console.print( + f"\nSelected license: {selected_license['name']} ({selected_license['key']})" + ) + if Confirm.ask("\nGenerate this license?"): selected_licenses = [selected_license] break else: console.print("\nPlease select a license again:") continue - + except ValueError: console.print("[red]Invalid input. Please enter a number.") continue - + # Generate each license for license in selected_licenses: license_type = license["key"] @@ -127,19 +139,23 @@ def generate(type: str, author: str, year: int, output: str, multi: bool): else: base, ext = os.path.splitext(output) output_file = f"{base}_{license_type}{ext}" - + try: output_path = generate_license( license_type=license_type, author=author, year=year, - output_file=output_file + output_file=output_file, + ) + console.print( + f"[green]✓[/green] Generated {license_type} license at: {output_path}" ) - console.print(f"[green]✓[/green] Generated {license_type} license at: {output_path}") except ValueError as e: - console.print(f"[red]Error generating {license_type} license:[/red] {str(e)}") + console.print( + f"[red]Error generating {license_type} license:[/red] {str(e)}" + ) continue - + # Create a combined README section if len(selected_licenses) > 1: readme_path = "LICENSE.md" @@ -148,64 +164,73 @@ def generate(type: str, author: str, year: int, output: str, multi: bool): f.write("This project is licensed under multiple licenses:\n\n") for license in selected_licenses: f.write(f"- [{license['name']}](LICENSE_{license['key']})\n") - console.print(f"\n[green]✓[/green] Created license summary at: {readme_path}") - + console.print( + f"\n[green]✓[/green] Created license summary at: {readme_path}" + ) + except ValueError as e: console.print(f"[red]Error:[/red] {str(e)}") except Exception as e: console.print(f"[red]Error:[/red] {str(e)}") + @license.command() def list(): """List available licenses.""" show_licenses() + def format_permissions(permissions: list) -> str: """Format permissions list into a readable string.""" if not permissions: return "N/A" - + # Map permission keys to readable names permission_map = { "commercial-use": "Commercial Use", "modifications": "Modifications", "distribution": "Distribution", "private-use": "Private Use", - "patent-use": "Patent Use" + "patent-use": "Patent Use", } - + # Convert each permission to its readable name readable_permissions = [ - permission_map.get(perm, perm.replace("-", " ").title()) - for perm in permissions + permission_map.get(perm, perm.replace("-", " ").title()) for perm in permissions ] - + return ", ".join(readable_permissions) + def truncate_description(description: str, max_length: int = 60) -> str: """Truncate description to a maximum length and add ellipsis if needed.""" if not description: return "No description available" - + # Remove HTML tags - description = re.sub(r'<[^>]+>', '', description) - + description = re.sub(r"<[^>]+>", "", description) + # Remove URLs - description = re.sub(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', '', description) - + description = re.sub( + r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", + "", + description, + ) + # Clean up whitespace - description = ' '.join(description.split()) - + description = " ".join(description.split()) + if len(description) <= max_length: return description - + # Find the last space before max_length last_space = description[:max_length].rfind(" ") if last_space == -1: last_space = max_length - + return description[:last_space] + "..." + def show_licenses(): """Display available licenses in a table.""" try: @@ -214,36 +239,41 @@ def show_licenses(): github_token = config.get("github_token") if github_token: os.environ["GITHUB_TOKEN"] = github_token - + # Fetch available licenses licenses = license_generator.fetch_available_licenses() - + if not licenses: console.print("[yellow]No licenses available") return - + # Create table table = Table(show_header=True, header_style="bold magenta") table.add_column("License Key", style="dim", width=15) table.add_column("Name", width=30) table.add_column("Permissions", width=40) - + # Add rows for license in licenses: table.add_row( license["key"], license["name"], - format_permissions(license.get("permissions", [])) + format_permissions(license.get("permissions", [])), ) - + # Display table console.print("\nAvailable Licenses:") console.print(table) - console.print("\nTo generate a license, use: [bold]devtools license generate[/bold]") - console.print("To generate multiple licenses, use: [bold]devtools license generate --multi[/bold]") - + console.print( + "\nTo generate a license, use: [bold]devtools license generate[/bold]" + ) + console.print( + "To generate multiple licenses, use: [bold]devtools license generate --multi[/bold]" + ) + except Exception as e: console.print(f"[red]Error:[/red] {str(e)}") + if __name__ == "__main__": - license() \ No newline at end of file + license() diff --git a/devtools/licensegen/generator.py b/devtools/licensegen/generator.py index 5b66648..7eab8fd 100644 --- a/devtools/licensegen/generator.py +++ b/devtools/licensegen/generator.py @@ -5,126 +5,144 @@ import requests from typing import Dict, List, Optional from ..shared.config import Config + class LicenseGenerator: """License generator using GitHub API.""" - + def __init__(self): """Initialize the license generator.""" self.api_base_url = "https://api.github.com" self.config = Config() - + def _get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" # Try to get token from config first token = self.config.get("github_token") - + # Then try environment variable if not token: token = os.environ.get("GITHUB_TOKEN") - + headers = { "Accept": "application/vnd.github.v3+json", - "User-Agent": "DevTools/1.0" + "User-Agent": "DevTools/1.0", } - + if token: headers["Authorization"] = f"Bearer {token}" - + return headers - + def fetch_available_licenses(self) -> List[Dict]: """Fetch available licenses from GitHub API.""" try: # Get headers with token headers = self._get_headers() - + # First check rate limit rate_limit_response = requests.get( - f"{self.api_base_url}/rate_limit", - headers=headers + f"{self.api_base_url}/rate_limit", headers=headers ) if rate_limit_response.status_code == 200: rate_limit_data = rate_limit_response.json() - + # Check if we have remaining requests - remaining = rate_limit_data.get("resources", {}).get("core", {}).get("remaining", 0) + remaining = ( + rate_limit_data.get("resources", {}) + .get("core", {}) + .get("remaining", 0) + ) if remaining == 0: - raise ValueError("GitHub API rate limit exceeded. Please set a GitHub token using:\ndevtools config set GITHUB_TOKEN YOUR_GITHUB_TOKEN") - + raise ValueError( + "GitHub API rate limit exceeded. Please set a GitHub token using:\ndevtools config set GITHUB_TOKEN YOUR_GITHUB_TOKEN" + ) + # Fetch list of licenses - response = requests.get( - f"{self.api_base_url}/licenses", - headers=headers - ) - + response = requests.get(f"{self.api_base_url}/licenses", headers=headers) + if response.status_code == 403: - raise ValueError("GitHub API rate limit exceeded. Please set a GitHub token using:\ndevtools config set GITHUB_TOKEN YOUR_GITHUB_TOKEN") - + raise ValueError( + "GitHub API rate limit exceeded. Please set a GitHub token using:\ndevtools config set GITHUB_TOKEN YOUR_GITHUB_TOKEN" + ) + response.raise_for_status() licenses = response.json() - + # Fetch detailed info for each license detailed_licenses = [] for license in licenses: try: license_response = requests.get( f"{self.api_base_url}/licenses/{license['key']}", - headers=headers + headers=headers, ) if license_response.status_code == 200: license_data = license_response.json() - detailed_licenses.append({ - "key": license_data["key"], - "name": license_data["name"], - "description": license_data.get("description", "No description available"), - "permissions": license_data.get("permissions", []), - "conditions": license_data.get("conditions", []), - "limitations": license_data.get("limitations", []) - }) + detailed_licenses.append( + { + "key": license_data["key"], + "name": license_data["name"], + "description": license_data.get( + "description", "No description available" + ), + "permissions": license_data.get("permissions", []), + "conditions": license_data.get("conditions", []), + "limitations": license_data.get("limitations", []), + } + ) except Exception: continue - + return detailed_licenses - + except requests.exceptions.RequestException as e: raise ValueError(f"Error fetching licenses: {str(e)}") - + def get_license_template(self, license_type: str) -> str: """Get license template from GitHub API.""" try: response = requests.get( f"{self.api_base_url}/licenses/{license_type}", - headers=self._get_headers() + headers=self._get_headers(), ) response.raise_for_status() return response.json()["body"] except requests.exceptions.RequestException as e: raise ValueError(f"Error fetching license template: {str(e)}") - - def generate_license(self, license_type: str, author: str, year: Optional[int] = None) -> str: + + def generate_license( + self, license_type: str, author: str, year: Optional[int] = None + ) -> str: """Generate license content with author and year.""" template = self.get_license_template(license_type) - + # Replace placeholders content = template.replace("[year]", str(year) if year else "") content = content.replace("[fullname]", author) - + return content + # Create a singleton instance license_generator = LicenseGenerator() -def generate_license(license_type: str, author: str, year: Optional[int] = None, output_file: str = "LICENSE") -> str: + +def generate_license( + license_type: str, + author: str, + year: Optional[int] = None, + output_file: str = "LICENSE", +) -> str: """Generate a license file.""" try: # Generate license content content = license_generator.generate_license(license_type, author, year) - + # Write to file with open(output_file, "w") as f: f.write(content) - + return output_file - + except Exception as e: - raise ValueError(f"Error generating license: {str(e)}") \ No newline at end of file + raise ValueError(f"Error generating license: {str(e)}") diff --git a/devtools/shared/__init__.py b/devtools/shared/__init__.py index 48c483c..6b2a538 100644 --- a/devtools/shared/__init__.py +++ b/devtools/shared/__init__.py @@ -6,4 +6,4 @@ from .config import BaseConfig from .git import GitService from .ai import AIService -__all__ = ["BaseConfig", "GitService", "AIService"] \ No newline at end of file +__all__ = ["BaseConfig", "GitService", "AIService"] diff --git a/devtools/shared/ai.py b/devtools/shared/ai.py index cf34d7e..0934027 100644 --- a/devtools/shared/ai.py +++ b/devtools/shared/ai.py @@ -1,84 +1,100 @@ """ Shared AI service for devtools. """ + import requests import os from typing import List, Dict, Optional, Any from abc import ABC, abstractmethod from .config import BaseConfig + class AIProvider(ABC): """Base class for AI providers.""" - + @abstractmethod def setup(self, config: BaseConfig) -> None: """Set up the provider with configuration.""" pass - + @abstractmethod - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: """Generate a completion from the AI model.""" pass + class OpenRouterProvider(AIProvider): """OpenRouter AI provider implementation.""" - + def setup(self, config: BaseConfig) -> None: self.config = config self.api_key = config.get_env_or_config("OPENROUTER_API_KEY") if not self.api_key: - raise ValueError("OpenRouter API key not found. Please set it in config or environment.") - + raise ValueError( + "OpenRouter API key not found. Please set it in config or environment." + ) + self.model = config.get("model", "mistralai/mixtral-8x7b-instruct") self.api_url = "https://openrouter.ai/api/v1/chat/completions" self.headers = { "Authorization": f"Bearer {self.api_key}", "HTTP-Referer": "https://github.com/S4NKALP/DevTools", "X-Title": "DevTools", - "Content-Type": "application/json" + "Content-Type": "application/json", } - def _create_prompt(self, system_prompt: str, user_prompt: str) -> List[Dict[str, str]]: + def _create_prompt( + self, system_prompt: str, user_prompt: str + ) -> List[Dict[str, str]]: return [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} + {"role": "user", "content": user_prompt}, ] - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: messages = self._create_prompt(system_prompt, user_prompt) - + try: - temp = temperature if temperature is not None else float(self.config.get("temperature", 0.7)) - tokens = max_tokens if max_tokens is not None else int(self.config.get("max_tokens", 150)) - + temp = ( + temperature + if temperature is not None + else float(self.config.get("temperature", 0.7)) + ) + tokens = ( + max_tokens + if max_tokens is not None + else int(self.config.get("max_tokens", 150)) + ) + payload = { "model": self.model, "messages": messages, "temperature": temp, "max_tokens": tokens, "top_p": float(top_p), - "stream": False + "stream": False, } - + payload = {k: v for k, v in payload.items() if v is not None} - + response = requests.post( - self.api_url, - headers=self.headers, - json=payload, - timeout=30 + self.api_url, headers=self.headers, json=payload, timeout=30 ) - + if response.status_code != 200: error_msg = response.text try: @@ -88,68 +104,80 @@ class OpenRouterProvider(AIProvider): except: pass raise Exception(f"OpenRouter API error: {error_msg}") - + result = response.json() - + if "choices" in result and len(result["choices"]) > 0: return result["choices"][0]["message"]["content"].strip() - + return "" - + except Exception as e: raise Exception(f"AI generation failed: {str(e)}") + class OpenAIProvider(AIProvider): """OpenAI provider implementation.""" - + def setup(self, config: BaseConfig) -> None: self.config = config self.api_key = config.get_env_or_config("OPENAI_API_KEY") if not self.api_key: - raise ValueError("OpenAI API key not found. Please set it in config or environment.") - + raise ValueError( + "OpenAI API key not found. Please set it in config or environment." + ) + self.model = config.get("model", "gpt-4-turbo-preview") self.api_url = "https://api.openai.com/v1/chat/completions" self.headers = { "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - def _create_prompt(self, system_prompt: str, user_prompt: str) -> List[Dict[str, str]]: + def _create_prompt( + self, system_prompt: str, user_prompt: str + ) -> List[Dict[str, str]]: return [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} + {"role": "user", "content": user_prompt}, ] - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: messages = self._create_prompt(system_prompt, user_prompt) - + try: - temp = temperature if temperature is not None else float(self.config.get("temperature", 0.7)) - tokens = max_tokens if max_tokens is not None else int(self.config.get("max_tokens", 150)) - + temp = ( + temperature + if temperature is not None + else float(self.config.get("temperature", 0.7)) + ) + tokens = ( + max_tokens + if max_tokens is not None + else int(self.config.get("max_tokens", 150)) + ) + payload = { "model": self.model, "messages": messages, "temperature": temp, "max_tokens": tokens, - "top_p": float(top_p) + "top_p": float(top_p), } - + payload = {k: v for k, v in payload.items() if v is not None} - + response = requests.post( - self.api_url, - headers=self.headers, - json=payload, - timeout=30 + self.api_url, headers=self.headers, json=payload, timeout=30 ) - + if response.status_code != 200: error_msg = response.text try: @@ -159,63 +187,72 @@ class OpenAIProvider(AIProvider): except: pass raise Exception(f"OpenAI API error: {error_msg}") - + result = response.json() - + if "choices" in result and len(result["choices"]) > 0: return result["choices"][0]["message"]["content"].strip() - + return "" - + except Exception as e: raise Exception(f"AI generation failed: {str(e)}") + class GeminiProvider(AIProvider): """Google Gemini provider implementation.""" - + def setup(self, config: BaseConfig) -> None: self.config = config self.api_key = config.get_env_or_config("GOOGLE_API_KEY") if not self.api_key: - raise ValueError("Google API key not found. Please set it in config or environment.") - + raise ValueError( + "Google API key not found. Please set it in config or environment." + ) + self.model = config.get("model", "gemini-pro") self.api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent" - self.headers = { - "Content-Type": "application/json" - } + self.headers = {"Content-Type": "application/json"} - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: try: - temp = temperature if temperature is not None else float(self.config.get("temperature", 0.7)) - tokens = max_tokens if max_tokens is not None else int(self.config.get("max_tokens", 150)) - + temp = ( + temperature + if temperature is not None + else float(self.config.get("temperature", 0.7)) + ) + tokens = ( + max_tokens + if max_tokens is not None + else int(self.config.get("max_tokens", 150)) + ) + # Combine system and user prompts for Gemini full_prompt = f"{system_prompt}\n\n{user_prompt}" - + payload = { - "contents": [{ - "parts": [{"text": full_prompt}] - }], + "contents": [{"parts": [{"text": full_prompt}]}], "generationConfig": { "temperature": temp, "maxOutputTokens": tokens, - "topP": float(top_p) - } + "topP": float(top_p), + }, } - + response = requests.post( f"{self.api_url}?key={self.api_key}", headers=self.headers, json=payload, - timeout=30 + timeout=30, ) - + if response.status_code != 200: error_msg = response.text try: @@ -225,69 +262,81 @@ class GeminiProvider(AIProvider): except: pass raise Exception(f"Gemini API error: {error_msg}") - + result = response.json() - + if "candidates" in result and len(result["candidates"]) > 0: return result["candidates"][0]["content"]["parts"][0]["text"].strip() - + return "" - + except Exception as e: raise Exception(f"AI generation failed: {str(e)}") + class ClaudeProvider(AIProvider): """Anthropic Claude provider implementation.""" - + def setup(self, config: BaseConfig) -> None: self.config = config self.api_key = config.get_env_or_config("ANTHROPIC_API_KEY") if not self.api_key: - raise ValueError("Anthropic API key not found. Please set it in config or environment.") - + raise ValueError( + "Anthropic API key not found. Please set it in config or environment." + ) + self.model = config.get("model", "claude-3-opus-20240229") self.api_url = "https://api.anthropic.com/v1/messages" self.headers = { "x-api-key": self.api_key, "anthropic-version": "2023-06-01", - "content-type": "application/json" + "content-type": "application/json", } - def _create_prompt(self, system_prompt: str, user_prompt: str) -> List[Dict[str, str]]: + def _create_prompt( + self, system_prompt: str, user_prompt: str + ) -> List[Dict[str, str]]: return [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} + {"role": "user", "content": user_prompt}, ] - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: messages = self._create_prompt(system_prompt, user_prompt) - + try: - temp = temperature if temperature is not None else float(self.config.get("temperature", 0.7)) - tokens = max_tokens if max_tokens is not None else int(self.config.get("max_tokens", 150)) - + temp = ( + temperature + if temperature is not None + else float(self.config.get("temperature", 0.7)) + ) + tokens = ( + max_tokens + if max_tokens is not None + else int(self.config.get("max_tokens", 150)) + ) + payload = { "model": self.model, "messages": messages, "temperature": temp, "max_tokens": tokens, - "top_p": float(top_p) + "top_p": float(top_p), } - + payload = {k: v for k, v in payload.items() if v is not None} - + response = requests.post( - self.api_url, - headers=self.headers, - json=payload, - timeout=30 + self.api_url, headers=self.headers, json=payload, timeout=30 ) - + if response.status_code != 200: error_msg = response.text try: @@ -297,66 +346,76 @@ class ClaudeProvider(AIProvider): except: pass raise Exception(f"Claude API error: {error_msg}") - + result = response.json() - + if "content" in result and len(result["content"]) > 0: return result["content"][0]["text"].strip() - + return "" - + except Exception as e: raise Exception(f"AI generation failed: {str(e)}") + class HuggingFaceProvider(AIProvider): """Hugging Face provider implementation.""" - + def setup(self, config: BaseConfig) -> None: self.config = config self.api_key = config.get_env_or_config("HUGGINGFACE_API_KEY") if not self.api_key: - raise ValueError("Hugging Face API key not found. Please set it in config or environment.") - + raise ValueError( + "Hugging Face API key not found. Please set it in config or environment." + ) + self.model = config.get("model", "mistralai/Mixtral-8x7B-Instruct-v0.1") self.api_url = f"https://api-inference.huggingface.co/models/{self.model}" self.headers = { "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json", } def _create_prompt(self, system_prompt: str, user_prompt: str) -> str: # Format prompt according to model's requirements return f"[INST] {system_prompt}\n\n{user_prompt} [/INST]" - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: try: - temp = temperature if temperature is not None else float(self.config.get("temperature", 0.7)) - tokens = max_tokens if max_tokens is not None else int(self.config.get("max_tokens", 150)) - + temp = ( + temperature + if temperature is not None + else float(self.config.get("temperature", 0.7)) + ) + tokens = ( + max_tokens + if max_tokens is not None + else int(self.config.get("max_tokens", 150)) + ) + prompt = self._create_prompt(system_prompt, user_prompt) - + payload = { "inputs": prompt, "parameters": { "temperature": temp, "max_new_tokens": tokens, "top_p": float(top_p), - "return_full_text": False - } + "return_full_text": False, + }, } - + response = requests.post( - self.api_url, - headers=self.headers, - json=payload, - timeout=30 + self.api_url, headers=self.headers, json=payload, timeout=30 ) - + if response.status_code != 200: error_msg = response.text try: @@ -366,20 +425,21 @@ class HuggingFaceProvider(AIProvider): except: pass raise Exception(f"Hugging Face API error: {error_msg}") - + result = response.json() - + if isinstance(result, list) and len(result) > 0: return result[0]["generated_text"].strip() - + return "" - + except Exception as e: raise Exception(f"AI generation failed: {str(e)}") + class AIService: """Base AI service that can be extended by specific tools.""" - + def __init__(self, config: BaseConfig): """Initialize AI service with configuration.""" self.config = config @@ -389,48 +449,47 @@ class AIService: def _get_provider(self) -> AIProvider: """Get the appropriate AI provider based on configuration.""" provider = self.config.get("provider", "openrouter").lower() - + providers = { "openrouter": OpenRouterProvider, "openai": OpenAIProvider, "gemini": GeminiProvider, "claude": ClaudeProvider, - "huggingface": HuggingFaceProvider + "huggingface": HuggingFaceProvider, } - + if provider not in providers: - raise ValueError(f"Unsupported AI provider: {provider}. Supported providers: {', '.join(providers.keys())}") - + raise ValueError( + f"Unsupported AI provider: {provider}. Supported providers: {', '.join(providers.keys())}" + ) + return providers[provider]() - def generate_completion(self, - system_prompt: str, - user_prompt: str, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - top_p: float = 0.95) -> str: + def generate_completion( + self, + system_prompt: str, + user_prompt: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + top_p: float = 0.95, + ) -> str: """Generate a completion from the AI model.""" return self.provider.generate_completion( - system_prompt, - user_prompt, - temperature, - max_tokens, - top_p + system_prompt, user_prompt, temperature, max_tokens, top_p ) - def generate_batch_completions(self, - system_prompt: str, - prompts: List[str], - temperature: Optional[float] = None, - max_tokens: Optional[int] = None) -> List[str]: + def generate_batch_completions( + self, + system_prompt: str, + prompts: List[str], + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> List[str]: """Generate completions for multiple prompts.""" completions = [] for prompt in prompts: completion = self.generate_completion( - system_prompt, - prompt, - temperature, - max_tokens + system_prompt, prompt, temperature, max_tokens ) completions.append(completion) - return completions \ No newline at end of file + return completions diff --git a/devtools/shared/config.py b/devtools/shared/config.py index 6c9a0a4..7bc7d3d 100644 --- a/devtools/shared/config.py +++ b/devtools/shared/config.py @@ -1,71 +1,75 @@ """ Shared configuration system. """ + import os import yaml from pathlib import Path from typing import Any, Dict, Optional + class BaseConfig: """Base configuration class.""" - + def __init__(self, default_config: Dict[str, Any] = None): """Initialize base configuration. - + Args: default_config: Default configuration values """ self._config = default_config or {} - + def get(self, key: str, default: Any = None) -> Any: """Get a configuration value. - + Args: key: Configuration key default: Default value if key not found - + Returns: Configuration value """ return self._config.get(key, default) - + def set(self, key: str, value: Any) -> None: """Set a configuration value. - + Args: key: Configuration key value: Configuration value """ self._config[key] = value - + def delete(self, key: str) -> None: """Delete a configuration value. - + Args: key: Configuration key """ if key in self._config: del self._config[key] - + def get_all(self) -> Dict[str, Any]: """Get all configuration values. - + Returns: Dictionary of all configuration values """ return self._config.copy() - + def clear(self) -> None: """Clear all configuration values.""" self._config = {} - + def validate(self) -> bool: """Override in subclasses for validation logic.""" return True - - def get_env_or_config(self, key: str, env_var: Optional[str] = None) -> Optional[str]: + + def get_env_or_config( + self, key: str, env_var: Optional[str] = None + ) -> Optional[str]: """Get value from environment variable or config. - + Args: key: Configuration key env_var: Environment variable name (defaults to key.upper()) @@ -73,9 +77,10 @@ class BaseConfig: env_var = env_var or key.upper() return self._config.get(key) or os.getenv(env_var) + class Config(BaseConfig): """Configuration manager for devtools.""" - + def __init__(self): """Initialize configuration.""" super().__init__() @@ -83,19 +88,19 @@ class Config(BaseConfig): self.config_file = self.config_dir / "config.yaml" self._ensure_config_dir() self._load_config() - + def _get_config_dir(self) -> Path: """Get the configuration directory. - + Returns: Path to config directory """ return Path.home() / ".devtools" - + def _ensure_config_dir(self) -> None: """Ensure configuration directory exists.""" self.config_dir.mkdir(parents=True, exist_ok=True) - + def _load_config(self) -> None: """Load configuration from file.""" if self.config_file.exists(): @@ -103,32 +108,32 @@ class Config(BaseConfig): self._config = yaml.safe_load(f) or {} else: self._config = {} - + def _save_config(self) -> None: """Save configuration to file.""" with open(self.config_file, "w") as f: yaml.dump(self._config, f, default_flow_style=False, sort_keys=False) - + def set(self, key: str, value: Any) -> None: """Set a configuration value. - + Args: key: Configuration key value: Configuration value """ super().set(key, value) self._save_config() - + def delete(self, key: str) -> None: """Delete a configuration value. - + Args: key: Configuration key """ super().delete(key) self._save_config() - + def clear(self) -> None: """Clear all configuration values.""" super().clear() - self._save_config() \ No newline at end of file + self._save_config() diff --git a/devtools/shared/git.py b/devtools/shared/git.py index c8f86bd..4410d36 100644 --- a/devtools/shared/git.py +++ b/devtools/shared/git.py @@ -1,19 +1,21 @@ """ Shared Git service for devtools. """ + import subprocess from pathlib import Path from typing import List, Dict, Optional, Tuple import os from datetime import datetime, timedelta + class GitService: """Base Git service that can be extended by specific tools.""" - + def __init__(self, repo_path: str): """Initialize GitService with repository path.""" self.repo_path = repo_path - if not os.path.exists(os.path.join(repo_path, '.git')): + if not os.path.exists(os.path.join(repo_path, ".git")): raise ValueError(f"Not a git repository: {repo_path}") def _is_git_repo(self) -> bool: @@ -23,7 +25,7 @@ class GitService: ["git", "rev-parse", "--is-inside-work-tree"], cwd=self.repo_path, check=True, - capture_output=True + capture_output=True, ) return True except subprocess.CalledProcessError: @@ -37,7 +39,7 @@ class GitService: cwd=self.repo_path, check=True, capture_output=True, - text=True + text=True, ) return result.stdout.strip(), result.stderr.strip(), result.returncode except subprocess.CalledProcessError as e: @@ -50,7 +52,9 @@ class GitService: def get_unstaged_files(self) -> List[str]: """Get list of unstaged files.""" - stdout, _, _ = self._run_git_command(["ls-files", "--modified", "--others", "--exclude-standard"]) + stdout, _, _ = self._run_git_command( + ["ls-files", "--modified", "--others", "--exclude-standard"] + ) return stdout.splitlines() if stdout else [] def get_diff(self, file_path: str, staged: bool = True) -> str: @@ -100,36 +104,36 @@ class GitService: if files: for file in files: result = subprocess.run( - ['git', 'diff', '--staged', '--', file], + ["git", "diff", "--staged", "--", file], cwd=self.repo_path, capture_output=True, - text=True + text=True, ) if result.stdout: diffs[file] = result.stdout else: result = subprocess.run( - ['git', 'diff', '--staged'], + ["git", "diff", "--staged"], cwd=self.repo_path, capture_output=True, - text=True + text=True, ) if result.stdout: # Parse the diff to get individual file changes current_file = None current_diff = [] - for line in result.stdout.split('\n'): - if line.startswith('diff --git'): + for line in result.stdout.split("\n"): + if line.startswith("diff --git"): if current_file and current_diff: - diffs[current_file] = '\n'.join(current_diff) - current_file = line.split('b/')[-1].strip() + diffs[current_file] = "\n".join(current_diff) + current_file = line.split("b/")[-1].strip() current_diff = [line] elif current_file: current_diff.append(line) if current_file and current_diff: - diffs[current_file] = '\n'.join(current_diff) + diffs[current_file] = "\n".join(current_diff) return diffs except subprocess.CalledProcessError as e: @@ -138,17 +142,15 @@ class GitService: def stage_all_changes(self) -> None: """Stage all changes in the repository.""" try: - subprocess.run( - ['git', 'add', '.'], - cwd=self.repo_path, - check=True - ) + subprocess.run(["git", "add", "."], cwd=self.repo_path, check=True) except subprocess.CalledProcessError as e: raise Exception(f"Failed to stage changes: {str(e)}") - def get_commit_history(self, since: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, str]]: + def get_commit_history( + self, since: Optional[str] = None, limit: Optional[int] = None + ) -> List[Dict[str, str]]: """Get commit history. - + Args: since: Get commits since this reference (tag, commit, etc.) limit: Maximum number of commits to return @@ -156,76 +158,78 @@ class GitService: args = [ "log", "--pretty=format:%H%n%an%n%ad%n%B%n==END==", - "--date=format:%Y-%m-%d %H:%M:%S %z" + "--date=format:%Y-%m-%d %H:%M:%S %z", ] - + if since: args.append(f"{since}..HEAD") if limit: args.append(f"-n {limit}") - + stdout, _, _ = self._run_git_command(args) - + commits = [] current_commit = [] - - for line in stdout.split('\n'): + + for line in stdout.split("\n"): if line == "==END==": if current_commit: commit_hash, author, date, *message_lines = current_commit - commits.append({ - 'hash': commit_hash, - 'author': author, - 'date': date, - 'message': '\n'.join(message_lines) - }) + commits.append( + { + "hash": commit_hash, + "author": author, + "date": date, + "message": "\n".join(message_lines), + } + ) current_commit = [] else: current_commit.append(line) - + return commits def get_commits_since_tag(self, tag: str) -> List[Dict[str, str]]: """Get commits since a tag. - + Args: tag: Git tag to use as reference point - + Returns: List of commit dictionaries with hash and message """ cmd = ["git", "log", f"{tag}..HEAD", "--pretty=format:%H|%s"] result = subprocess.run(cmd, capture_output=True, text=True, cwd=self.repo_path) result.check_returncode() - + commits = [] for line in result.stdout.splitlines(): hash_, message = line.split("|", 1) commits.append({"hash": hash_, "message": message}) - + return commits def get_commits_since_date(self, days: int) -> List[Dict[str, str]]: """Get commits since a date. - + Args: days: Number of days to look back - + Returns: List of commit dictionaries with hash and message """ # Calculate date date = datetime.now() - timedelta(days=days) date_str = date.strftime("%Y-%m-%d") - + # Get commits cmd = ["git", "log", f"--since={date_str}", "--pretty=format:%H|%s"] result = subprocess.run(cmd, capture_output=True, text=True, cwd=self.repo_path) result.check_returncode() - + commits = [] for line in result.stdout.splitlines(): hash_, message = line.split("|", 1) commits.append({"hash": hash_, "message": message}) - - return commits \ No newline at end of file + + return commits