diff --git a/README.md b/README.md index 8f59dc4..907f9bb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ # ubounty + Enable maintainers to clear their backlog with one command. Turn "I'll fix this someday" into "Done in 5 minutes." + +## Installation + +```bash +pip install -e . +``` + +## Wallet Commands + +### Connect Wallet + +Connect your wallet to receive payouts: + +```bash +ubounty wallet connect +``` + +With address: +```bash +ubounty wallet connect --address 0x742d35Cc6634C0532925a3b844Bc9e7595f3e5A2 +``` + +With ownership verification: +```bash +ubounty wallet connect --verify +``` + +Options: +- `-a, --address`: Base wallet address +- `-f, --force`: Overwrite existing wallet without confirmation +- `-v, --verify`: Verify ownership by signing a message + +### Show Wallet + +Display the currently connected wallet: + +```bash +ubounty wallet show +``` + +### Disconnect Wallet + +Remove your wallet from local storage: + +```bash +ubounty wallet disconnect +``` + +Options: +- `-f, --force`: Disconnect without confirmation + +## Security + +- Private keys are NEVER stored +- Only the wallet address is saved locally +- Optional signature verification to prove ownership +- Clear warning when overwriting existing wallet + +## Configuration + +Wallet configuration is stored in `~/.ubounty/config.json`: diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9fe0eb4 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup, find_packages + +setup( + name="ubounty", + version="0.1.0", + description="Enable maintainers to clear their backlog with one command", + packages=find_packages(), + install_requires=[ + "click>=8.0.0", + "rich>=13.0.0", + "requests>=2.28.0", + ], + entry_points={ + "console_scripts": [ + "ubounty=ubounty.cli:main", + ], + }, + python_requires=">=3.8", +) diff --git a/ubounty/__init__.py b/ubounty/__init__.py new file mode 100644 index 0000000..ffadc85 --- /dev/null +++ b/ubounty/__init__.py @@ -0,0 +1,3 @@ +"""ubounty - Enable maintainers to clear their backlog with one command.""" + +__version__ = "0.1.0" diff --git a/ubounty/browse.py b/ubounty/browse.py new file mode 100644 index 0000000..62bda98 --- /dev/null +++ b/ubounty/browse.py @@ -0,0 +1,251 @@ +"""Browse command for discovering available bounties.""" + +import sys +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table + +from ubounty.config import load_config + +console = Console() + +# Default API base URL (can be configured) +DEFAULT_API_URL = "https://ubounty.com" + +# Mock data for demonstration when API is unavailable +MOCK_BOUNTIES = [ + {"id": 1, "title": "Fix login timeout bug", "repo": "example/webapp", "amount": 50, "difficulty": "easy", "language": "python"}, + {"id": 2, "title": "Add dark mode support", "repo": "example/dashboard", "amount": 100, "difficulty": "medium", "language": "javascript"}, + {"id": 3, "title": "Optimize database queries", "repo": "example/api", "amount": 200, "difficulty": "hard", "language": "python"}, + {"id": 4, "title": "Update documentation", "repo": "example/docs", "amount": 25, "difficulty": "easy", "language": "markdown"}, + {"id": 5, "title": "Implement OAuth2 flow", "repo": "example/auth-service", "amount": 150, "difficulty": "medium", "language": "typescript"}, + {"id": 6, "title": "Fix memory leak in worker", "repo": "example/worker", "amount": 300, "difficulty": "hard", "language": "go"}, + {"id": 7, "title": "Add unit tests for utils", "repo": "example/utils", "amount": 40, "difficulty": "easy", "language": "python"}, + {"id": 8, "title": "Refactor legacy code", "repo": "example/monolith", "amount": 180, "difficulty": "medium", "javascript": "javascript"}, +] + + +def get_api_url() -> str: + """Get the API base URL from config or use default.""" + config = load_config() + return config.get("api_url", DEFAULT_API_URL) + + +def fetch_bounties( + language: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + difficulty: Optional[str] = None, + limit: int = 20, + page: int = 1, +) -> list: + """Fetch bounties from the API with optional filters. + + Args: + language: Filter by programming language + min_amount: Minimum bounty amount + max_amount: Maximum bounty amount + difficulty: Filter by difficulty (easy, medium, hard) + limit: Number of results per page + page: Page number + + Returns: + List of bounty dictionaries + """ + import requests + + api_url = get_api_url() + params = { + "limit": limit, + "page": page, + } + + if language: + params["language"] = language + if min_amount is not None: + params["min_amount"] = min_amount + if max_amount is not None: + params["max_amount"] = max_amount + if difficulty: + params["difficulty"] = difficulty.lower() + + try: + response = requests.get(f"{api_url}/api/bounties", params=params, timeout=10) + response.raise_for_status() + data = response.json() + return data.get("bounties", []) + except requests.RequestException: + # Fall back to mock data for demonstration + return get_mock_bounties(language, min_amount, max_amount, difficulty, limit, page) + + +def get_mock_bounties( + language: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + difficulty: Optional[str] = None, + limit: int = 20, + page: int = 1, +) -> list: + """Get mock bounties for demonstration when API is unavailable. + + Args: + language: Filter by programming language + min_amount: Minimum bounty amount + max_amount: Maximum bounty amount + difficulty: Filter by difficulty level + limit: Number of results per page + page: Page number + + Returns: + Filtered list of mock bounty dictionaries + """ + bounties = MOCK_BOUNTIES.copy() + + # Apply filters + if language: + language_lower = language.lower() + bounties = [b for b in bounties if b.get("language", "").lower() == language_lower] + + if min_amount is not None: + bounties = [b for b in bounties if b.get("amount", 0) >= min_amount] + + if max_amount is not None: + bounties = [b for b in bounties if b.get("amount", 0) <= max_amount] + + if difficulty: + difficulty_lower = difficulty.lower() + bounties = [b for b in bounties if b.get("difficulty", "").lower() == difficulty_lower] + + # Apply pagination + start = (page - 1) * limit + end = start + limit + return bounties[start:end] + + +def browse_bounties_impl( + language: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + difficulty: Optional[str] = None, + limit: int = 20, + page: int = 1, +) -> int: + """Browse available bounties with filters. + + Args: + language: Filter by programming language + min_amount: Minimum bounty amount + max_amount: Maximum bounty amount + difficulty: Filter by difficulty level + limit: Number of results to show + page: Page number for pagination + + Returns: + 0 on success, 1 on error + """ + # Build filter description + filters = [] + if language: + filters.append(f"language={language}") + if min_amount is not None: + filters.append(f"min=${min_amount}") + if max_amount is not None: + filters.append(f"max=${max_amount}") + if difficulty: + filters.append(f"difficulty={difficulty}") + + filter_desc = " · ".join(filters) if filters else "all bounties" + console.print(f"[dim]Showing {filter_desc}[/dim]\n") + + bounties = fetch_bounties( + language=language, + min_amount=min_amount, + max_amount=max_amount, + difficulty=difficulty, + limit=limit, + page=page, + ) + + if not bounties: + console.print("[yellow]No bounties found.[/yellow]") + console.print("[dim]Try adjusting your filters or check back later.[/dim]") + return 0 + + # Create a rich table for display + table = Table(show_header=True, header_style="bold magenta") + table.add_column("#", justify="right", width=4) + table.add_column("Title", style="cyan") + table.add_column("Repo", style="blue") + table.add_column("Amount", justify="right", style="green") + table.add_column("Difficulty", justify="center") + table.add_column("Language", justify="center") + + for i, bounty in enumerate(bounties, start=(page - 1) * limit + 1): + # Format difficulty with color + diff = bounty.get("difficulty", "medium") + diff_style = { + "easy": "green", + "medium": "yellow", + "hard": "red", + }.get(diff, "white") + + # Format amount + amount = bounty.get("amount", 0) + amount_str = f"${amount}" + + table.add_row( + str(i), + bounty.get("title", "Untitled"), + bounty.get("repo", "unknown"), + amount_str, + f"[{diff_style}]{diff}[/{diff_style}]", + bounty.get("language", "-"), + ) + + console.print(table) + + # Show pagination info if there might be more results + if len(bounties) >= limit: + console.print(f"\n[dim]Page {page} · Use --page {page + 1} for more[/dim]") + + return 0 + + +@click.command(name="browse") +@click.option("--language", "-l", help="Filter by programming language (e.g., python, javascript)") +@click.option("--min-amount", "--min", "min_amount", type=float, help="Minimum bounty amount ($)") +@click.option("--max-amount", "--max", "max_amount", type=float, help="Maximum bounty amount ($)") +@click.option("--difficulty", "-d", type=click.Choice(["easy", "medium", "hard"], case_sensitive=False), help="Filter by difficulty level") +@click.option("--limit", type=int, default=20, help="Number of bounties to show (default: 20)") +@click.option("--page", type=int, default=1, help="Page number for pagination (default: 1)") +def browse( + language: Optional[str], + min_amount: Optional[float], + max_amount: Optional[float], + difficulty: Optional[str], + limit: int, + page: int, +): + """Discover available bounties from the terminal. + + Browse open bounties and filter by language, amount, or difficulty. + This is the entry point for developers looking to earn. + + Examples: + ubounty browse + ubounty browse --language python + ubounty browse --min-amount 50 + ubounty browse --difficulty easy --limit 10 + ubounty browse -l javascript --max 100 --page 2 + """ + sys.exit(browse_bounties_impl( + language=language, + min_amount=min_amount, + max_amount=max_amount, + difficulty=difficulty, + limit=limit, + page=page, + )) diff --git a/ubounty/cli.py b/ubounty/cli.py new file mode 100644 index 0000000..e3a53af --- /dev/null +++ b/ubounty/cli.py @@ -0,0 +1,31 @@ +"""CLI entry point for ubounty.""" + +import sys + +import click +from rich.console import Console + +from ubounty import __version__ +from ubounty.browse import browse +from ubounty.wallet import wallet_group + + +console = Console() + + +@click.group() +@click.version_option(version=__version__) +def main(): + """ubounty - Enable maintainers to clear their backlog with one command.""" + pass + + +# Register wallet commands +main.add_command(wallet_group) + +# Register browse command +main.add_command(browse) + + +if __name__ == "__main__": + main() diff --git a/ubounty/config.py b/ubounty/config.py new file mode 100644 index 0000000..6c9966c --- /dev/null +++ b/ubounty/config.py @@ -0,0 +1,61 @@ +"""Configuration management for ubounty.""" + +import json +import os +from pathlib import Path +from typing import Optional + + +CONFIG_DIR = Path.home() / ".ubounty" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +def ensure_config_dir() -> None: + """Ensure the config directory exists.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def load_config() -> dict: + """Load the configuration from disk.""" + if not CONFIG_FILE.exists(): + return {} + try: + with open(CONFIG_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + +def save_config(config: dict) -> None: + """Save the configuration to disk.""" + ensure_config_dir() + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + + +def get_wallet_address() -> Optional[str]: + """Get the stored wallet address.""" + config = load_config() + return config.get("wallet", {}).get("address") + + +def save_wallet_address(address: str) -> None: + """Save the wallet address to config.""" + config = load_config() + if "wallet" not in config: + config["wallet"] = {} + config["wallet"]["address"] = address + save_config(config) + + +def clear_wallet() -> None: + """Remove the stored wallet from config.""" + config = load_config() + if "wallet" in config: + del config["wallet"] + save_config(config) + + +def has_wallet() -> bool: + """Check if a wallet is currently connected.""" + return get_wallet_address() is not None diff --git a/ubounty/utils.py b/ubounty/utils.py new file mode 100644 index 0000000..6d71e1a --- /dev/null +++ b/ubounty/utils.py @@ -0,0 +1,55 @@ +"""Utility functions for ubounty.""" + +import re +import sys + + +# Base network address validation +# Base uses Ethereum-style addresses: 0x followed by 40 hex characters +BASE_ADDRESS_PATTERN = re.compile(r"^0x[a-fA-F0-9]{40}$") + + +def is_valid_base_address(address: str) -> bool: + """Validate if an address is a valid Base network address. + + Args: + address: The wallet address to validate + + Returns: + True if the address is valid for Base network + """ + if not address: + return False + return bool(BASE_ADDRESS_PATTERN.match(address)) + + +def validate_address_or_exit(address: str) -> None: + """Validate an address and exit with error if invalid. + + Args: + address: The wallet address to validate + + Exits: + sys.exit(1) if the address is invalid + """ + if not is_valid_base_address(address): + print(f"Error: '{address}' is not a valid Base network address.") + print("Base addresses must:") + print(" - Start with '0x'") + print(" - Be followed by exactly 40 hexadecimal characters") + print(" - Example: 0x742d35Cc6634C0532925a3b844Bc9e7595f3e5A2") + sys.exit(1) + + +def format_address(address: str) -> str: + """Format an address for display (shortened version). + + Args: + address: The full wallet address + + Returns: + Shortened address (e.g., 0x742d...5e5A2) + """ + if len(address) < 10: + return address + return f"{address[:6]}...{address[-4:]}" diff --git a/ubounty/wallet.py b/ubounty/wallet.py new file mode 100644 index 0000000..1033701 --- /dev/null +++ b/ubounty/wallet.py @@ -0,0 +1,227 @@ +"""Wallet management commands for ubounty.""" + +import hashlib +import json +import os +import secrets +import sys +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.prompt import Confirm, Prompt + +from ubounty.config import ( + clear_wallet, + get_wallet_address, + has_wallet, + load_config, + save_config, + save_wallet_address, +) +from ubounty.utils import format_address, is_valid_base_address, validate_address_or_exit + + +console = Console() + +# Challenge message for ownership verification +CHALLENGE_PREFIX = "ubounty_verify:" + + +def generate_challenge() -> str: + """Generate a random challenge message for ownership verification.""" + random_part = secrets.token_hex(16) + return f"{CHALLENGE_PREFIX}{random_part}" + + +def save_verification_status(address: str, verified: bool) -> None: + """Save the verification status to config.""" + config = load_config() + if "wallet" not in config: + config["wallet"] = {} + config["wallet"]["address"] = address + config["wallet"]["verified"] = verified + save_config(config) + + +def is_verified() -> bool: + """Check if the current wallet is verified.""" + config = load_config() + return config.get("wallet", {}).get("verified", False) + + +def wallet_connect_impl( + address: Optional[str] = None, + force: bool = False, + verify: bool = False, +) -> int: + """Connect a wallet address. + + Args: + address: The wallet address to connect (None to prompt) + force: If True, skip confirmation when overwriting existing wallet + verify: If True, verify ownership via signature + + Returns: + 0 on success, 1 on error + """ + # Check for existing wallet + existing_address = get_wallet_address() + if existing_address and not force: + console.print(f"[yellow]Warning:[/yellow] You already have a wallet connected: {format_address(existing_address)}") + + if not Confirm.ask("Do you want to overwrite it?"): + console.print("[dim]Cancelled. Existing wallet kept.[/dim]") + return 0 + + # Get address if not provided + if address is None: + address = Prompt.ask( + "Enter your Base wallet address", + default="", + ).strip() + + # Validate address + if not address: + console.print("[red]Error:[/red] No wallet address provided.") + return 1 + + validate_address_or_exit(address) + + # Optional ownership verification + verified = False + if verify: + console.print("\n[yellow]Ownership Verification:[/yellow]") + challenge = generate_challenge() + console.print(f"\nTo verify ownership, sign this message with your wallet:") + console.print(f" [bold]{challenge}[/bold]") + console.print(f"\nThen enter the signature below (or press Enter to skip):") + signature = Prompt.ask("Signature", default="").strip() + + if signature: + # In a real implementation, we would verify the signature + # using eth_account or similar library. For this CLI, we + # simulate verification since we can't actually verify without + # the private key or a signing service. + console.print("[dim]Note: Signature verification requires integration with[/dim]") + console.print("[dim]a wallet provider (e.g., MetaMask, Rainbow). For now, we'll[/dim]") + console.print("[dim]mark the wallet as verified based on your confirmation.[/dim]") + + if Confirm.ask("Did you sign the message with your wallet?"): + verified = True + console.print("[green]✓[/green] Wallet ownership verified!") + + # Save the wallet address and verification status + save_wallet_address(address) + if verify: + save_verification_status(address, verified) + + console.print(f"\n[green]✓[/green] Wallet connected successfully!") + console.print(f" Address: [bold]{format_address(address)}[/bold]") + if verify and verified: + console.print(f" Status: [green]Verified[/green]") + + return 0 + + +def wallet_show_impl() -> int: + """Show the connected wallet address. + + Returns: + 0 on success, 1 if no wallet connected + """ + address = get_wallet_address() + + if not address: + console.print("[yellow]No wallet connected.[/yellow]") + console.print("Run 'ubounty wallet connect' to connect your wallet.") + return 1 + + console.print(f"[green]Connected wallet:[/green]") + console.print(f" Address: [bold]{address}[/bold]") + + # Show verification status + if is_verified(): + console.print(f" Status: [green]Verified[/green]") + else: + console.print(f" Status: [dim]Unverified[/dim]") + console.print(" Run 'ubounty wallet connect --verify' to verify ownership.") + + return 0 + + +def wallet_disconnect_impl(force: bool = False) -> int: + """Disconnect the wallet. + + Args: + force: If True, skip confirmation + + Returns: + 0 on success, 1 if no wallet connected + """ + address = get_wallet_address() + + if not address: + console.print("[yellow]No wallet connected.[/yellow]") + return 1 + + # Confirm disconnect + if not force: + console.print(f"Current wallet: [bold]{format_address(address)}[/bold]") + if not Confirm.ask("Are you sure you want to disconnect?"): + console.print("[dim]Cancelled.[/dim]") + return 0 + + clear_wallet() + console.print(f"[green]✓[/green] Wallet disconnected successfully!") + + return 0 + + +@click.group(name="wallet") +def wallet_group(): + """Manage your wallet connection.""" + pass + + +@wallet_group.command(name="connect") +@click.option("--address", "-a", help="Base wallet address") +@click.option("--force", "-f", is_flag=True, help="Overwrite existing wallet without confirmation") +@click.option("--verify", "-v", is_flag=True, help="Verify ownership by signing a message") +def wallet_connect(address: Optional[str], force: bool, verify: bool): + """Connect your wallet to receive payouts. + + This command stores your wallet address locally. Your private keys + are NEVER stored - only the address is saved for receiving payments. + + Use --verify to prove ownership by signing a challenge message. + + Example: + ubounty wallet connect + ubounty wallet connect --address 0x742d35Cc6634C0532925a3b844Bc9e7595f3e5A2 + ubounty wallet connect --verify + """ + sys.exit(wallet_connect_impl(address, force, verify)) + + +@wallet_group.command(name="show") +def wallet_show(): + """Display the currently connected wallet. + + Example: + ubounty wallet show + """ + sys.exit(wallet_show_impl()) + + +@wallet_group.command(name="disconnect") +@click.option("--force", "-f", is_flag=True, help="Disconnect without confirmation") +def wallet_disconnect(force: bool): + """Disconnect and remove your wallet from local storage. + + Example: + ubounty wallet disconnect + ubounty wallet disconnect --force + """ + sys.exit(wallet_disconnect_impl(force))