diff --git a/src/gac/cli.py b/src/gac/cli.py index 92cf26f..9cd23be 100644 --- a/src/gac/cli.py +++ b/src/gac/cli.py @@ -24,6 +24,7 @@ from gac.language_cli import language as language_cli from gac.main import main from gac.model_cli import model as model_cli +from gac.prompt_cli import prompt as prompt_cli from gac.utils import setup_logging from gac.workflow_context import CLIOptions @@ -206,6 +207,7 @@ def cli( cli.add_command(init_cli) cli.add_command(language_cli) cli.add_command(model_cli) +cli.add_command(prompt_cli) @click.command(context_settings=language_cli.context_settings) diff --git a/src/gac/prompt_cli.py b/src/gac/prompt_cli.py new file mode 100644 index 0000000..9ab5cb0 --- /dev/null +++ b/src/gac/prompt_cli.py @@ -0,0 +1,266 @@ +"""CLI for managing custom system prompts.""" + +import logging +import os +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel + +logger = logging.getLogger(__name__) +console = Console() + +GAC_CONFIG_DIR = Path.home() / ".config" / "gac" +CUSTOM_PROMPT_FILE = GAC_CONFIG_DIR / "custom_system_prompt.txt" + + +def get_active_custom_prompt() -> tuple[str | None, str | None]: + """Return (content, source) for active custom prompt, or (None, None) if none. + + Returns: + Tuple of (content, source) where: + - content: The custom prompt text, or None if using default + - source: Human-readable description of where the prompt came from, or None if using default + """ + # Check GAC_SYSTEM_PROMPT_PATH env var first (highest precedence) + env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH") + if env_path: + env_file = Path(env_path) + if env_file.exists(): + try: + content = env_file.read_text(encoding="utf-8") + return content, f"GAC_SYSTEM_PROMPT_PATH={env_path}" + except OSError: + pass + + # Check stored custom prompt file + if CUSTOM_PROMPT_FILE.exists(): + try: + content = CUSTOM_PROMPT_FILE.read_text(encoding="utf-8") + return content, str(CUSTOM_PROMPT_FILE) + except OSError: + pass + + # No custom prompt configured + return None, None + + +@click.group() +def prompt(): + """Manage custom system prompts.""" + pass + + +@prompt.command() +def show() -> None: + """Show the active custom system prompt.""" + from gac.prompt import _load_default_system_template + + content, source = get_active_custom_prompt() + + if content is None: + console.print("[dim]No custom prompt configured. Showing default:[/dim]\n") + default_template = _load_default_system_template() + console.print(Panel(default_template.strip(), title="Default System Prompt", border_style="green")) + return + + # Determine title based on source + if source and source.startswith("GAC_SYSTEM_PROMPT_PATH="): + title = f"Custom System Prompt (from {source})" + else: + title = f"Custom System Prompt ({source})" + + console.print(Panel(content.strip(), title=title, border_style="green")) + + +def _edit_text_interactive(initial_text: str) -> str | None: + """Edit text interactively using prompt_toolkit. + + Returns edited text, or None if cancelled. + """ + from prompt_toolkit import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.document import Document + from prompt_toolkit.enums import EditingMode + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import HSplit, Layout, Window + from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.layout.margins import ScrollbarMargin + from prompt_toolkit.styles import Style + + try: + import shutil + + console.print("\n[bold]Edit your custom system prompt:[/bold]") + console.print("[dim]Esc+Enter or Ctrl+S to save | Ctrl+C to cancel[/dim]\n") + + # Create buffer for text editing + text_buffer = Buffer( + document=Document(text=initial_text, cursor_position=0), + multiline=True, + enable_history_search=False, + ) + + # Track state + cancelled = {"value": False} + submitted = {"value": False} + + # Get terminal size and calculate appropriate height + term_size = shutil.get_terminal_size((80, 24)) + # Reserve 6 lines for header, hint bar, and margins + available_height = max(5, term_size.lines - 6) + content_height = initial_text.count("\n") + 3 + editor_height = min(available_height, max(5, content_height)) + + # Create text editor window - adapt to terminal size + text_window = Window( + content=BufferControl(buffer=text_buffer, focus_on_click=True), + height=editor_height, + wrap_lines=True, + right_margins=[ScrollbarMargin()], + ) + + # Create hint window + hint_window = Window( + content=FormattedTextControl(text=[("class:hint", " Esc+Enter or Ctrl+S to save | Ctrl+C to cancel ")]), + height=1, + dont_extend_height=True, + ) + + # Create layout + root_container = HSplit([text_window, hint_window]) + layout = Layout(root_container, focused_element=text_window) + + # Create key bindings + kb = KeyBindings() + + @kb.add("c-s") + def _(event): + submitted["value"] = True + event.app.exit() + + @kb.add("c-c") + def _(event): + cancelled["value"] = True + event.app.exit() + + @kb.add("escape", "enter") + def _(event): + submitted["value"] = True + event.app.exit() + + # Create and run application + custom_style = Style.from_dict({"hint": "#888888"}) + + app: Application[None] = Application( + layout=layout, + key_bindings=kb, + full_screen=False, + mouse_support=False, + editing_mode=EditingMode.VI, + style=custom_style, + ) + + app.run() + + if cancelled["value"]: + return None + + if submitted["value"]: + return text_buffer.text.strip() + + return None + + except (EOFError, KeyboardInterrupt): + return None + except Exception as e: + logger.error(f"Error during interactive editing: {e}") + console.print(f"[red]Failed to open editor: {e}[/red]") + return None + + +def _get_prompt_file_to_edit() -> tuple[Path, str]: + """Get the file path to edit and its current content. + + Returns the env var path if set, otherwise the default stored path. + """ + env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH") + if env_path: + target_file = Path(env_path) + content = "" + if target_file.exists(): + try: + content = target_file.read_text(encoding="utf-8") + except OSError: + pass + return target_file, content + + # Default to stored config file + content = "" + if CUSTOM_PROMPT_FILE.exists(): + try: + content = CUSTOM_PROMPT_FILE.read_text(encoding="utf-8") + except OSError: + pass + return CUSTOM_PROMPT_FILE, content + + +@prompt.command() +@click.option("--edit", "-e", is_flag=True, help="Edit prompt interactively in terminal") +@click.option("--file", "file_path", type=click.Path(exists=True), help="Copy prompt from file") +def set(edit: bool, file_path: str | None) -> None: + """Set custom system prompt via interactive editor or file.""" + # Require exactly one of --edit or --file + if edit and file_path: + console.print("[red]Error: --edit and --file are mutually exclusive[/red]") + raise click.Abort() + + if not edit and not file_path: + console.print("[red]Error: either --edit or --file must be specified[/red]") + raise click.Abort() + + if edit: + # Get the target file and its current content + target_file, initial_content = _get_prompt_file_to_edit() + + # Create parent directory if needed + target_file.parent.mkdir(parents=True, exist_ok=True) + + # Open interactive editor + result = _edit_text_interactive(initial_content) + + if result is None: + console.print("\n[yellow]Edit cancelled, no changes made.[/yellow]") + return + + if not result: + console.print("\n[yellow]Empty prompt not saved.[/yellow]") + return + + # Save result + target_file.write_text(result, encoding="utf-8") + console.print(f"\n[green]Custom prompt saved to {target_file}[/green]") + + elif file_path: + # Copy file content + source_file = Path(file_path) + try: + content = source_file.read_text(encoding="utf-8") + # Create parent directory if needed + CUSTOM_PROMPT_FILE.parent.mkdir(parents=True, exist_ok=True) + CUSTOM_PROMPT_FILE.write_text(content, encoding="utf-8") + console.print(f"Custom prompt copied from {file_path} to {CUSTOM_PROMPT_FILE}") + except OSError as e: + console.print(f"[red]Error reading file {file_path}: {e}[/red]") + raise click.Abort() from e + + +@prompt.command() +def clear() -> None: + """Clear custom system prompt (revert to default).""" + if CUSTOM_PROMPT_FILE.exists(): + CUSTOM_PROMPT_FILE.unlink() + console.print(f"Custom prompt deleted: {CUSTOM_PROMPT_FILE}") + else: + console.print("No custom prompt file to delete.") diff --git a/tests/test_prompt_cli.py b/tests/test_prompt_cli.py new file mode 100644 index 0000000..ef7cfa4 --- /dev/null +++ b/tests/test_prompt_cli.py @@ -0,0 +1,265 @@ +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from gac.prompt_cli import ( + get_active_custom_prompt, + prompt, +) + + +@pytest.fixture +def mock_paths(tmp_path, monkeypatch): + """Override config directory and custom prompt file paths.""" + config_dir = tmp_path / "config" / "gac" + custom_prompt = config_dir / "custom_system_prompt.txt" + + monkeypatch.setattr("gac.prompt_cli.GAC_CONFIG_DIR", config_dir) + monkeypatch.setattr("gac.prompt_cli.CUSTOM_PROMPT_FILE", custom_prompt) + + return {"config_dir": config_dir, "custom_prompt": custom_prompt} + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestPromptShow: + def test_prompt_show_no_custom_prompt(self, runner, mock_paths, monkeypatch): + """No env var, no file, shows default system prompt.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + result = runner.invoke(prompt, ["show"]) + assert result.exit_code == 0 + assert "No custom prompt configured" in result.output + assert "Default System Prompt" in result.output + # Should contain actual default prompt content + assert "git commit message" in result.output.lower() + + def test_prompt_show_from_env_var(self, runner, tmp_path, monkeypatch): + """GAC_SYSTEM_PROMPT_PATH set, shows content with env var source.""" + env_prompt_file = tmp_path / "env_prompt.txt" + env_prompt_file.write_text("Env var custom prompt content", encoding="utf-8") + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", str(env_prompt_file)) + + result = runner.invoke(prompt, ["show"]) + assert result.exit_code == 0 + assert "Env var custom prompt content" in result.output + # Rich may wrap long paths, so check for key parts + output_normalized = result.output.replace("\n", "") + assert "GAC_SYSTEM_PROMPT_PATH=" in output_normalized + + def test_prompt_show_from_stored_file(self, runner, mock_paths, monkeypatch): + """File exists at CUSTOM_PROMPT_FILE, shows content with file path source.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Stored custom prompt content", encoding="utf-8") + + result = runner.invoke(prompt, ["show"]) + assert result.exit_code == 0 + assert "Stored custom prompt content" in result.output + # Panel shows title with source info + assert "Custom System Prompt" in result.output + + def test_prompt_show_env_var_precedence(self, runner, mock_paths, tmp_path, monkeypatch): + """Both env var and stored file exist, env var wins.""" + # Create stored file + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Stored prompt", encoding="utf-8") + + # Create env var file + env_prompt_file = tmp_path / "env_prompt.txt" + env_prompt_file.write_text("Env var prompt", encoding="utf-8") + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", str(env_prompt_file)) + + result = runner.invoke(prompt, ["show"]) + assert result.exit_code == 0 + assert "Env var prompt" in result.output + assert "Stored prompt" not in result.output + # Rich may wrap long paths, so check for key parts + output_normalized = result.output.replace("\n", "") + assert "GAC_SYSTEM_PROMPT_PATH=" in output_normalized + + +class TestPromptSetFile: + def test_prompt_set_file_success(self, runner, mock_paths, tmp_path, monkeypatch): + """Copies file content to CUSTOM_PROMPT_FILE.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + source_file = tmp_path / "source.txt" + source_file.write_text("Test prompt content", encoding="utf-8") + + result = runner.invoke(prompt, ["set", "--file", str(source_file)]) + assert result.exit_code == 0 + assert mock_paths["custom_prompt"].exists() + assert mock_paths["custom_prompt"].read_text(encoding="utf-8") == "Test prompt content" + assert "Custom prompt copied" in result.output + + def test_prompt_set_file_not_found(self, runner, mock_paths, monkeypatch): + """Error when file doesn't exist.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + nonexistent = "/path/that/does/not/exist.txt" + result = runner.invoke(prompt, ["set", "--file", nonexistent]) + assert result.exit_code != 0 + assert "does not exist" in result.output or "not found" in result.output.lower() + + def test_prompt_set_file_creates_directory(self, runner, mock_paths, tmp_path, monkeypatch): + """Creates ~/.config/gac if needed.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + source_file = tmp_path / "source.txt" + source_file.write_text("Test content", encoding="utf-8") + + # Ensure config_dir doesn't exist + assert not mock_paths["config_dir"].exists() + + result = runner.invoke(prompt, ["set", "--file", str(source_file)]) + assert result.exit_code == 0 + assert mock_paths["config_dir"].exists() + assert mock_paths["custom_prompt"].exists() + + +class TestPromptSetEdit: + def test_prompt_set_edit_new(self, runner, mock_paths, monkeypatch): + """Mock _edit_text_interactive(), verify content saved (no existing prompt).""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + + with patch("gac.prompt_cli._edit_text_interactive") as mock_edit: + mock_edit.return_value = "New prompt from editor" + result = runner.invoke(prompt, ["set", "--edit"]) + + assert result.exit_code == 0 + assert mock_paths["custom_prompt"].exists() + assert mock_paths["custom_prompt"].read_text(encoding="utf-8") == "New prompt from editor" + assert "Custom prompt saved" in result.output + # Verify editor was called with empty string + mock_edit.assert_called_once_with("") + + def test_prompt_set_edit_existing(self, runner, mock_paths, monkeypatch): + """Mock _edit_text_interactive(), verify pre-populated with existing content.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Existing prompt", encoding="utf-8") + + with patch("gac.prompt_cli._edit_text_interactive") as mock_edit: + mock_edit.return_value = "Updated prompt from editor" + result = runner.invoke(prompt, ["set", "--edit"]) + + assert result.exit_code == 0 + assert mock_paths["custom_prompt"].read_text(encoding="utf-8") == "Updated prompt from editor" + # Verify editor was called with existing content + mock_edit.assert_called_once_with("Existing prompt") + + def test_prompt_set_edit_abort(self, runner, mock_paths, monkeypatch): + """Mock _edit_text_interactive() returning None, verify no changes.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Original content", encoding="utf-8") + + with patch("gac.prompt_cli._edit_text_interactive") as mock_edit: + mock_edit.return_value = None # User cancelled + result = runner.invoke(prompt, ["set", "--edit"]) + + assert result.exit_code == 0 + # File should remain unchanged + assert mock_paths["custom_prompt"].read_text(encoding="utf-8") == "Original content" + assert "cancelled" in result.output.lower() + + def test_prompt_set_requires_option(self, runner, mock_paths, monkeypatch): + """Error if neither --edit nor --file provided.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + result = runner.invoke(prompt, ["set"]) + assert result.exit_code != 0 + assert "--edit" in result.output or "--file" in result.output + + def test_prompt_set_edit_env_var_file(self, runner, mock_paths, tmp_path, monkeypatch): + """When GAC_SYSTEM_PROMPT_PATH is set, --edit edits that file.""" + env_prompt_file = tmp_path / "env_prompt.txt" + env_prompt_file.write_text("Original env content", encoding="utf-8") + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", str(env_prompt_file)) + + with patch("gac.prompt_cli._edit_text_interactive") as mock_edit: + mock_edit.return_value = "Updated env content" + result = runner.invoke(prompt, ["set", "--edit"]) + + assert result.exit_code == 0 + # Editor should be called with env file content + mock_edit.assert_called_once_with("Original env content") + # Env file should be updated, not the default stored file + assert env_prompt_file.read_text(encoding="utf-8") == "Updated env content" + assert not mock_paths["custom_prompt"].exists() + + +class TestPromptClear: + def test_prompt_clear_existing(self, runner, mock_paths, monkeypatch): + """Deletes file, shows confirmation.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Prompt to delete", encoding="utf-8") + + result = runner.invoke(prompt, ["clear"]) + assert result.exit_code == 0 + assert not mock_paths["custom_prompt"].exists() + assert "deleted" in result.output.lower() + + def test_prompt_clear_nonexistent(self, runner, mock_paths, monkeypatch): + """No error when file doesn't exist (idempotent).""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + assert not mock_paths["custom_prompt"].exists() + + result = runner.invoke(prompt, ["clear"]) + assert result.exit_code == 0 + # Should succeed silently or with friendly message + assert "error" not in result.output.lower() + + +class TestGetActiveCustomPrompt: + def test_get_active_custom_prompt_none(self, mock_paths, monkeypatch): + """Returns (None, None) when no custom prompt.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + content, source = get_active_custom_prompt() + assert content is None + assert source is None + + def test_get_active_custom_prompt_env_var(self, tmp_path, monkeypatch): + """Returns content and source from env var.""" + env_prompt_file = tmp_path / "env_prompt.txt" + env_prompt_file.write_text("Env var content", encoding="utf-8") + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", str(env_prompt_file)) + + content, source = get_active_custom_prompt() + assert content == "Env var content" + assert source == f"GAC_SYSTEM_PROMPT_PATH={env_prompt_file}" + + def test_get_active_custom_prompt_stored_file(self, mock_paths, monkeypatch): + """Returns content and source from stored file.""" + monkeypatch.delenv("GAC_SYSTEM_PROMPT_PATH", raising=False) + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Stored content", encoding="utf-8") + + content, source = get_active_custom_prompt() + assert content == "Stored content" + assert source == str(mock_paths["custom_prompt"]) + + def test_get_active_custom_prompt_env_var_precedence(self, mock_paths, tmp_path, monkeypatch): + """Env var takes precedence over stored file.""" + # Create stored file + mock_paths["config_dir"].mkdir(parents=True, exist_ok=True) + mock_paths["custom_prompt"].write_text("Stored content", encoding="utf-8") + + # Create env var file + env_prompt_file = tmp_path / "env_prompt.txt" + env_prompt_file.write_text("Env var content", encoding="utf-8") + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", str(env_prompt_file)) + + content, source = get_active_custom_prompt() + assert content == "Env var content" + assert source == f"GAC_SYSTEM_PROMPT_PATH={env_prompt_file}" + + def test_get_active_custom_prompt_env_var_missing_file(self, mock_paths, monkeypatch): + """Returns (None, None) when env var points to nonexistent file.""" + monkeypatch.setenv("GAC_SYSTEM_PROMPT_PATH", "/nonexistent/file.txt") + + content, source = get_active_custom_prompt() + # Should gracefully handle missing file + assert content is None + assert source is None