Skip to content

Upgrade CLI to typer #1151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ readme = "README.md"
requires-python = ">=3.12, <3.14"
dependencies = [
"codegen-api-client",
"click>=8.1.7",
"rich-click>=1.8.5",
"typer>=0.12.5",
"rich>=13.7.1",
"hatch-vcs>=0.4.0",
"hatchling>=1.25.0",
Expand Down
6 changes: 6 additions & 0 deletions src/codegen/cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pydantic import BaseModel
from rich import print as rprint

from codegen.cli.api.endpoints import IDENTIFY_ENDPOINT
from codegen.cli.api.schemas import IdentifyResponse
from codegen.cli.env.global_env import global_env
from codegen.cli.errors import InvalidTokenError, ServerError

Expand Down Expand Up @@ -75,3 +77,7 @@
except requests.RequestException as e:
msg = f"Network error: {e!s}"
raise ServerError(msg)

def identify(self) -> IdentifyResponse:
"""Identify the current user with the authentication token."""
return self._make_request("GET", IDENTIFY_ENDPOINT, None, IdentifyResponse)

Check warning on line 83 in src/codegen/cli/api/client.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/api/client.py#L83

Added line #L83 was not covered by tests
11 changes: 9 additions & 2 deletions src/codegen/cli/auth/decorators.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import functools
import inspect
from collections.abc import Callable

import click
import rich
import typer

from codegen.cli.auth.login import login_routine
from codegen.cli.auth.session import CodegenSession
Expand All @@ -21,7 +22,7 @@
# Check for valid session
if session is None:
pretty_print_error("There is currently no active session.\nPlease run 'codegen init' to initialize the project.")
raise click.Abort()
raise typer.Abort()

Check warning on line 25 in src/codegen/cli/auth/decorators.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/decorators.py#L25

Added line #L25 was not covered by tests

if (token := get_current_token()) is None:
rich.print("[yellow]Not authenticated. Let's get you logged in first![/yellow]\n")
Expand All @@ -36,4 +37,10 @@

return f(*args, session=session, **kwargs)

# Remove the session parameter from the wrapper's signature so Typer doesn't see it
sig = inspect.signature(f)
new_params = [param for name, param in sig.parameters.items() if name != 'session']
new_sig = sig.replace(parameters=new_params)
wrapper.__signature__ = new_sig # type: ignore[attr-defined]

return wrapper
14 changes: 7 additions & 7 deletions src/codegen/cli/auth/login.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import webbrowser

import rich
import rich_click as click
import typer

from codegen.cli.api.webapp_routes import USER_SECRETS_ROUTE
from codegen.cli.auth.token_manager import TokenManager
Expand All @@ -19,7 +19,7 @@
str: The authenticated token

Raises:
click.ClickException: If login fails
typer.Exit: If login fails

"""
# Try environment variable first
Expand All @@ -29,11 +29,11 @@
if not token:
rich.print(f"Opening {USER_SECRETS_ROUTE} to get your authentication token...")
webbrowser.open_new(USER_SECRETS_ROUTE)
token = click.prompt("Please enter your authentication token from the browser", hide_input=False)
token = typer.prompt("Please enter your authentication token from the browser", hide_input=False)

Check warning on line 32 in src/codegen/cli/auth/login.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/login.py#L32

Added line #L32 was not covered by tests

if not token:
msg = "Token must be provided via CODEGEN_USER_ACCESS_TOKEN environment variable or manual input"
raise click.ClickException(msg)
rich.print("[red]Error:[/red] Token must be provided via CODEGEN_USER_ACCESS_TOKEN environment variable or manual input")
raise typer.Exit(1)

Check warning on line 36 in src/codegen/cli/auth/login.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/login.py#L35-L36

Added lines #L35 - L36 were not covered by tests

# Validate and store token
try:
Expand All @@ -44,5 +44,5 @@
rich.print("To opt out, set [green]telemetry_enabled = false[/green] in [cyan]~/.config/codegen-sh/analytics.json[/cyan] ✨")
return token
except AuthError as e:
msg = f"Error: {e!s}"
raise click.ClickException(msg)
rich.print(f"[red]Error:[/red] {e!s}")
raise typer.Exit(1)

Check warning on line 48 in src/codegen/cli/auth/login.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/login.py#L47-L48

Added lines #L47 - L48 were not covered by tests
12 changes: 6 additions & 6 deletions src/codegen/cli/auth/session.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pathlib import Path

import click
import rich
import typer
from github import BadCredentialsException
from github.MainClass import Github

Expand All @@ -24,14 +24,14 @@
def __init__(self, repo_path: Path, git_token: str | None = None) -> None:
if not repo_path.exists():
rich.print(f"\n[bold red]Error:[/bold red] Path to git repo does not exist at {repo_path}")
raise click.Abort()
raise typer.Abort()

Check warning on line 27 in src/codegen/cli/auth/session.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/session.py#L27

Added line #L27 was not covered by tests

# Check if it's a valid git repository
try:
LocalGitRepo(repo_path=repo_path)
except Exception:
rich.print(f"\n[bold red]Error:[/bold red] Path {repo_path} is not a valid git repository")
raise click.Abort()
raise typer.Abort()

Check warning on line 34 in src/codegen/cli/auth/session.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/session.py#L34

Added line #L34 was not covered by tests

self.repo_path = repo_path
self.local_git = LocalGitRepo(repo_path=repo_path)
Expand Down Expand Up @@ -82,12 +82,12 @@
rich.print(format_command("git remote add origin <your-repo-url>"))

try:
if git_token is not None:
if git_token is not None and self.local_git.full_name is not None:

Check warning on line 85 in src/codegen/cli/auth/session.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/session.py#L85

Added line #L85 was not covered by tests
Github(login_or_token=git_token).get_repo(self.local_git.full_name)
except BadCredentialsException:
rich.print(format_command(f"\n[bold red]Error:[/bold red] Invalid GitHub token={git_token} for repo={self.local_git.full_name}"))
rich.print("[white]Please provide a valid GitHub token for this repository.[/white]")
raise click.Abort()
raise typer.Abort()

Check warning on line 90 in src/codegen/cli/auth/session.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/session.py#L90

Added line #L90 was not covered by tests

def __str__(self) -> str:
return f"CodegenSession(user={self.config.repository.user_name}, repo={self.config.repository.repo_name})"
return f"CodegenSession(user={self.config.repository.user_name}, repo={self.config.repository.name})"

Check warning on line 93 in src/codegen/cli/auth/session.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/auth/session.py#L93

Added line #L93 was not covered by tests
61 changes: 40 additions & 21 deletions src/codegen/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,53 @@
import rich_click as click
import typer
from rich.traceback import install

# Removed reference to non-existent agent module
# Import config command (still a Typer app)
from codegen.cli.commands.config.main import config_command
from codegen.cli.commands.init.main import init_command
from codegen.cli.commands.login.main import login_command
from codegen.cli.commands.logout.main import logout_command
from codegen.cli.commands.profile.main import profile_command
from codegen.cli.commands.style_debug.main import style_debug_command
from codegen.cli.commands.update.main import update_command
from codegen import __version__

install(show_locals=True)


@click.group(name="codegen")
@click.version_option(prog_name="codegen", message="%(version)s")
def main():
def version_callback(value: bool):
"""Print version and exit."""
if value:
print(__version__)
raise typer.Exit()

# Create the main Typer app
main = typer.Typer(
name="codegen",
help="Codegen CLI - Transform your code with AI.",
rich_markup_mode="rich"
)

# Import the actual command functions
from codegen.cli.commands.init.main import init
from codegen.cli.commands.login.main import login
from codegen.cli.commands.logout.main import logout
from codegen.cli.commands.profile.main import profile
from codegen.cli.commands.style_debug.main import style_debug
from codegen.cli.commands.update.main import update

# Add individual commands to the main app
main.command("init", help="Initialize or update the Codegen folder.")(init)
main.command("login", help="Store authentication token.")(login)
main.command("logout", help="Clear stored authentication token.")(logout)
main.command("profile", help="Display information about the currently authenticated user.")(profile)
main.command("style-debug", help="Debug command to visualize CLI styling (spinners, etc).")(style_debug)
main.command("update", help="Update Codegen to the latest or specified version")(update)

# Config is a group, so add it as a typer
main.add_typer(config_command, name="config")


@main.callback()
def main_callback(
version: bool = typer.Option(False, "--version", callback=version_callback, is_eager=True, help="Show version and exit")
):
"""Codegen CLI - Transform your code with AI."""
pass


# Add commands to the main group
main.add_command(init_command)
main.add_command(logout_command)
main.add_command(login_command)
main.add_command(profile_command)
main.add_command(style_debug_command)
main.add_command(update_command)
main.add_command(config_command)


if __name__ == "__main__":
main()
21 changes: 9 additions & 12 deletions src/codegen/cli/commands/config/main.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import logging

import rich
import rich_click as click
import typer
from rich.table import Table

from codegen.configs.constants import ENV_FILENAME, GLOBAL_ENV_FILE
from codegen.configs.user_config import UserConfig
from codegen.shared.path import get_git_root_path


@click.group(name="config")
def config_command():
"""Manage codegen configuration."""
pass
# Create a Typer app for the config command
config_command = typer.Typer(help="Manage codegen configuration.")


@config_command.command(name="list")
def list_command():
def list_config():
"""List current configuration values."""

def flatten_dict(data: dict, prefix: str = "") -> dict:
Expand Down Expand Up @@ -80,8 +77,7 @@ def flatten_dict(data: dict, prefix: str = "") -> dict:


@config_command.command(name="get")
@click.argument("key")
def get_command(key: str):
def get_config(key: str = typer.Argument(..., help="Configuration key to get")):
"""Get a configuration value."""
config = _get_user_config()
if not config.has_key(key):
Expand All @@ -94,9 +90,10 @@ def get_command(key: str):


@config_command.command(name="set")
@click.argument("key")
@click.argument("value")
def set_command(key: str, value: str):
def set_config(
key: str = typer.Argument(..., help="Configuration key to set"),
value: str = typer.Argument(..., help="Configuration value to set")
):
"""Set a configuration value and write to .env"""
config = _get_user_config()
if not config.has_key(key):
Expand Down
29 changes: 17 additions & 12 deletions src/codegen/cli/commands/init/main.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import sys
from pathlib import Path
from typing import Optional

import rich
import rich_click as click
import typer

from codegen.cli.auth.session import CodegenSession
from codegen.cli.rich.codeblocks import format_command
from codegen.shared.path import get_git_root_path


@click.command(name="init")
@click.option("--path", type=str, help="Path within a git repository. Defaults to the current directory.")
@click.option("--token", type=str, help="Access token for the git repository. Required for full functionality.")
@click.option("--language", type=click.Choice(["python", "typescript"], case_sensitive=False), help="Override automatic language detection")
@click.option("--fetch-docs", is_flag=True, help="Fetch docs and examples (requires auth)")
def init_command(path: str | None = None, token: str | None = None, language: str | None = None, fetch_docs: bool = False):
def init(
path: Optional[str] = typer.Option(None, help="Path within a git repository. Defaults to the current directory."),
token: Optional[str] = typer.Option(None, help="Access token for the git repository. Required for full functionality."),
language: Optional[str] = typer.Option(None, help="Override automatic language detection (python or typescript)"),
fetch_docs: bool = typer.Option(False, "--fetch-docs", help="Fetch docs and examples (requires auth)")
):
"""Initialize or update the Codegen folder."""
# Validate language option
if language and language.lower() not in ["python", "typescript"]:
rich.print(f"[bold red]Error:[/bold red] Invalid language '{language}'. Must be 'python' or 'typescript'.")
raise typer.Exit(1)

Check warning on line 22 in src/codegen/cli/commands/init/main.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/commands/init/main.py#L20-L22

Added lines #L20 - L22 were not covered by tests

# Print a message if not in a git repo
path = Path.cwd() if path is None else Path(path)
repo_path = get_git_root_path(path)
current_path = Path.cwd() if path is None else Path(path)
repo_path = get_git_root_path(current_path)

Check warning on line 26 in src/codegen/cli/commands/init/main.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/commands/init/main.py#L25-L26

Added lines #L25 - L26 were not covered by tests
rich.print(f"Found git repository at: {repo_path}")

if repo_path is None:
rich.print(f"\n[bold red]Error:[/bold red] Path={path} is not in a git repository")
rich.print(f"\n[bold red]Error:[/bold red] Path={current_path} is not in a git repository")

Check warning on line 30 in src/codegen/cli/commands/init/main.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/commands/init/main.py#L30

Added line #L30 was not covered by tests
rich.print("[white]Please run this command from within a git repository.[/white]")
rich.print("\n[dim]To initialize a new git repository:[/dim]")
rich.print(format_command("git init"))
rich.print(format_command("codegen init"))
sys.exit(1)
raise typer.Exit(1)

Check warning on line 35 in src/codegen/cli/commands/init/main.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/commands/init/main.py#L35

Added line #L35 was not covered by tests

session = CodegenSession(repo_path=repo_path, git_token=token)
if language:
Expand Down
13 changes: 6 additions & 7 deletions src/codegen/cli/commands/login/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import rich_click as click
from typing import Optional
import typer
import rich

from codegen.cli.auth.login import login_routine
from codegen.cli.auth.token_manager import get_current_token


@click.command(name="login")
@click.option("--token", required=False, help="API token for authentication")
def login_command(token: str):
def login(token: Optional[str] = typer.Option(None, help="API token for authentication")):
"""Store authentication token."""
# Check if already authenticated
if get_current_token():
msg = "Already authenticated. Use 'codegen logout' to clear the token."
raise click.ClickException(msg)
rich.print("[yellow]Warning:[/yellow] Already authenticated. Use 'codegen logout' to clear the token.")
raise typer.Exit(1)

Check warning on line 13 in src/codegen/cli/commands/login/main.py

View check run for this annotation

Codecov / codecov/patch

src/codegen/cli/commands/login/main.py#L12-L13

Added lines #L12 - L13 were not covered by tests

login_routine(token)
6 changes: 2 additions & 4 deletions src/codegen/cli/commands/logout/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import rich
import rich_click as click
import typer

from codegen.cli.auth.token_manager import TokenManager


@click.command(name="logout")
def logout_command():
def logout():
"""Clear stored authentication token."""
token_manager = TokenManager()
token_manager.clear_token()
Expand Down
8 changes: 3 additions & 5 deletions src/codegen/cli/commands/profile/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import rich
import rich_click as click
import typer
from rich import box
from rich.panel import Panel

Expand All @@ -13,16 +13,14 @@ def requires_init(func):
"""Simple stub decorator that does nothing."""
return func


@click.command(name="profile")
@requires_auth
@requires_init
def profile_command(session: CodegenSession):
def profile(session: CodegenSession):
"""Display information about the currently authenticated user."""
repo_config = session.config.repository
rich.print(
Panel(
f"[cyan]Name:[/cyan] {repo_config.user_name}\n[cyan]Email:[/cyan] {repo_config.user_email}\n[cyan]Repo:[/cyan] {repo_config.repo_name}",
f"[cyan]Name:[/cyan] {repo_config.user_name}\n[cyan]Email:[/cyan] {repo_config.user_email}\n[cyan]Repo:[/cyan] {repo_config.name}",
title="🔑 [bold]Current Profile[/bold]",
border_style="cyan",
box=box.ROUNDED,
Expand Down
7 changes: 2 additions & 5 deletions src/codegen/cli/commands/style_debug/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

import time

import rich_click as click
import typer

from codegen.cli.rich.spinners import create_spinner


@click.command(name="style-debug")
@click.option("--text", default="Loading...", help="Text to show in the spinner")
def style_debug_command(text: str):
def style_debug(text: str = typer.Option("Loading...", help="Text to show in the spinner")):
"""Debug command to visualize CLI styling (spinners, etc)."""
try:
with create_spinner(text) as status:
Expand Down
Loading
Loading