diff --git a/.gitignore b/.gitignore index 4fef2be..6b69ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ cython_debug/ gitmastery-exercises/ Gemfile.lock + +# AI settings +.claude/ diff --git a/app/cli.py b/app/cli.py index f202821..355dbb1 100644 --- a/app/cli.py +++ b/app/cli.py @@ -4,7 +4,7 @@ import click import requests -from app.commands import check, download, progress, setup, verify +from app.commands import check, download, progress, repl, setup, verify from app.commands.version import version from app.utils.click import ClickColor, CliContextKey, warn from app.utils.version import Version @@ -21,7 +21,11 @@ def invoke(self, ctx: click.Context) -> None: CONTEXT_SETTINGS = {"max_content_width": 120} -@click.group(cls=LoggingGroup, context_settings=CONTEXT_SETTINGS) +@click.group( + cls=LoggingGroup, + context_settings=CONTEXT_SETTINGS, + invoke_without_command=True, +) @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") @click.pass_context def cli(ctx: click.Context, verbose: bool) -> None: @@ -51,9 +55,12 @@ def cli(ctx: click.Context, verbose: bool) -> None: f"Follow the update guide here: {click.style('https://git-mastery.org/companion-app/index.html#updating-the-git-mastery-app', bold=True)}" ) + if ctx.invoked_subcommand is None and not ctx.resilient_parsing: + ctx.invoke(repl) + def start() -> None: - commands = [check, download, progress, setup, verify, version] + commands = [check, download, progress, repl, setup, verify, version] for command in commands: cli.add_command(command) cli(obj={}) diff --git a/app/commands/__init__.py b/app/commands/__init__.py index 1455c3e..7d59f09 100644 --- a/app/commands/__init__.py +++ b/app/commands/__init__.py @@ -1,8 +1,9 @@ -__all__ = ["check", "download", "progress", "setup", "verify", "version"] +__all__ = ["check", "download", "progress", "repl", "setup", "verify", "version"] from .check import check from .download import download from .progress.progress import progress +from .repl import repl from .setup_folder import setup from .verify import verify from .version import version diff --git a/app/commands/repl.py b/app/commands/repl.py new file mode 100644 index 0000000..f2b9514 --- /dev/null +++ b/app/commands/repl.py @@ -0,0 +1,223 @@ +import cmd +import os +import shlex +import subprocess +import sys +from typing import List + +import click + +from app.commands.check import check +from app.commands.download import download +from app.commands.progress.progress import progress +from app.commands.setup_folder import setup +from app.commands.verify import verify +from app.commands.version import version +from app.utils.click import CliContextKey, ClickColor +from app.utils.version import Version +from app.version import __version__ + + +GITMASTERY_COMMANDS = { + "check": check, + "download": download, + "progress": progress, + "setup": setup, + "verify": verify, + "version": version, +} + + +class GitMasteryREPL(cmd.Cmd): + """Interactive REPL for Git-Mastery commands.""" + + intro_msg = r""" + _____ _ _ ___ ___ _ +| __ (_) | | \/ | | | +| | \/_| |_| . . | __ _ ___| |_ ___ _ __ _ _ +| | __| | __| |\/| |/ _` / __| __/ _ \ '__| | | | +| |_\ \ | |_| | | | (_| \__ \ || __/ | | |_| | + \____/_|\__\_| |_/\__,_|___/\__\___|_| \__, | + __/ | + |___/ + +Welcome to the Git-Mastery REPL! +Type '/help' for available commands, or '/exit' to quit. +Use /command to run Git-Mastery commands (e.g. /verify), or 'gitmastery command'. +Shell commands are also supported. + """ + + intro = click.style( + intro_msg, + bold=True, + fg=ClickColor.BRIGHT_CYAN, + ) + + def __init__(self) -> None: + super().__init__() + self._update_prompt() + + def _update_prompt(self) -> None: + """Update prompt to show current directory.""" + cwd = os.path.basename(os.getcwd()) or os.getcwd() + self.prompt = f"gitmastery [{cwd}]> " + + def postcmd(self, stop: bool, line: str) -> bool: + """Update prompt after each command.""" + self._update_prompt() + return stop + + def precmd(self, line: str) -> str: + """Pre-process command line before execution.""" + stripped = line.strip() + if stripped.startswith("/"): + return "gitmastery " + stripped[1:] + return line + + def default(self, line: str) -> bool: + """Handle commands not recognized by cmd module.""" + try: + parts = shlex.split(line) + except ValueError as e: + click.echo(click.style(f"Input error: {e}", fg=ClickColor.BRIGHT_RED)) + return False + + if not parts: + return False + + command_name = parts[0] + args = parts[1:] + + if command_name.lower() == "gitmastery": + gitmastery_command = args[0] + if gitmastery_command in ("exit", "quit"): + return self.do_exit("") + elif gitmastery_command == "help": + return self.do_help("") + elif gitmastery_command in GITMASTERY_COMMANDS: + self._run_gitmastery_command(gitmastery_command, args[1:]) + else: + click.echo( + click.style( + f"Unknown Git-Mastery command: {gitmastery_command}", + fg=ClickColor.BRIGHT_RED, + ) + ) + return False + + self._run_shell_command(line) + return False + + def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: + """Execute a gitmastery command.""" + command = GITMASTERY_COMMANDS[command_name] + original_cwd = os.getcwd() + try: + ctx = command.make_context(command_name, args) + ctx.ensure_object(dict) + ctx.obj[CliContextKey.VERBOSE] = False + ctx.obj[CliContextKey.VERSION] = Version.parse_version_string(__version__) + with ctx: + command.invoke(ctx) + except click.ClickException as e: + e.show() + except click.Abort: + click.echo("Aborted.") + except SystemExit: + pass + except Exception as e: + click.echo(click.style(f"Error: {e}", fg=ClickColor.BRIGHT_RED)) + finally: + try: + os.chdir(original_cwd) + except (FileNotFoundError, PermissionError, OSError) as e: + click.echo( + click.style( + f"Warning: Could not restore original directory: {e}", + fg=ClickColor.BRIGHT_YELLOW, + ) + ) + + def _run_shell_command(self, line: str) -> None: + """Execute a shell command via subprocess.""" + try: + subprocess.run(line, shell=True) + except Exception as e: + click.echo(click.style(f"Shell error: {e}", fg=ClickColor.BRIGHT_RED)) + + def do_cd(self, path: str) -> bool: + """Change directory.""" + if not path: + path = os.path.expanduser("~") + else: + try: + parts = shlex.split(path) + path = parts[0] if parts else "" + except ValueError: + pass + try: + os.chdir(os.path.expanduser(path)) + except FileNotFoundError: + click.echo( + click.style(f"Directory not found: {path}", fg=ClickColor.BRIGHT_RED) + ) + except PermissionError: + click.echo( + click.style(f"Permission denied: {path}", fg=ClickColor.BRIGHT_RED) + ) + except OSError as e: + click.echo( + click.style(f"Cannot change directory: {e}", fg=ClickColor.BRIGHT_RED) + ) + return False + + def do_exit(self, args: str) -> bool: + """Exit the Git-Mastery REPL.""" + click.echo(click.style("Goodbye!", fg=ClickColor.BRIGHT_CYAN)) + return True + + def do_quit(self, args: str) -> bool: + """Exit the Git-Mastery REPL.""" + return self.do_exit(args) + + def do_help(self, args: str) -> bool: + """Show help for commands.""" + click.echo( + click.style("\nGit-Mastery Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN) + ) + for name, command in GITMASTERY_COMMANDS.items(): + help_text = (command.help or "No description available.").strip() + click.echo(f" {click.style(f'/{name:<20}', bold=True)} {help_text}") + + click.echo( + click.style("\nBuilt-in Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN) + ) + for name, desc in [ + ("/help", "Show this help message"), + ("/exit", "Exit the REPL"), + ("/quit", "Exit the REPL"), + ]: + click.echo(f" {click.style(f'{name:<20}', bold=True)} {desc}") + click.echo() + return False + + def emptyline(self) -> bool: + """Do nothing on empty line (don't repeat last command).""" + return False + + def do_EOF(self, _arg: str) -> bool: + """Handle Ctrl+D.""" + click.echo() + return self.do_exit(_arg) + + +@click.command() +def repl() -> None: + """Start an interactive REPL session.""" + repl_instance = GitMasteryREPL() + + try: + repl_instance.cmdloop() + except KeyboardInterrupt: + click.echo(click.style("\nInterrupted. Goodbye!", fg=ClickColor.BRIGHT_CYAN)) + sys.exit(0) diff --git a/app/utils/gitmastery.py b/app/utils/gitmastery.py index a2f5ff6..6a850c1 100644 --- a/app/utils/gitmastery.py +++ b/app/utils/gitmastery.py @@ -33,6 +33,21 @@ ] +def _clear_exercise_utils_modules() -> None: + """Clear cached exercise_utils modules from sys.modules. + + This is especially important in REPL context where modules persist + between command invocations. + """ + modules_to_remove = [ + key + for key in sys.modules + if key == "exercise_utils" or key.startswith("exercise_utils.") + ] + for mod in modules_to_remove: + del sys.modules[mod] + + class ExercisesRepo: def __init__(self) -> None: """Creates a sparse clone of the exercises repository. @@ -126,6 +141,9 @@ def load_file_as_namespace( py_file = exercises_repo.fetch_file_contents(file_path, False) namespace: Dict[str, Any] = {} + # Clear any cached exercise_utils modules to ensure fresh imports + _clear_exercise_utils_modules() + with tempfile.TemporaryDirectory() as tmpdir: package_root = os.path.join(tmpdir, "exercise_utils") os.makedirs(package_root, exist_ok=True) @@ -142,8 +160,9 @@ def load_file_as_namespace( exec(py_file, namespace) finally: sys.path.remove(tmpdir) - - sys.dont_write_bytecode = False + # Clean up cached modules again after execution + _clear_exercise_utils_modules() + sys.dont_write_bytecode = False return cls(namespace) def execute_function(