Skip to content
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
2 changes: 2 additions & 0 deletions src/gac/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
266 changes: 266 additions & 0 deletions src/gac/prompt_cli.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +209 to +256
Copy link

@coderabbitai coderabbitai bot Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent behavior between --edit and --file options.

Lines 222-242 show that --edit respects GAC_SYSTEM_PROMPT_PATH (via _get_prompt_file_to_edit), but lines 244-255 show that --file always writes to CUSTOM_PROMPT_FILE, ignoring the environment variable.

This creates confusing behavior:

  1. User sets GAC_SYSTEM_PROMPT_PATH=/custom/path
  2. User runs gac prompt set --file my.txt
  3. File is copied to ~/.config/gac/custom_system_prompt.txt
  4. User runs gac prompt show
  5. Still sees content from /custom/path because env var takes precedence
  6. User's --file operation appears to have no effect

Apply this diff to make --file consistent with --edit:

     elif file_path:
         # Copy file content
         source_file = Path(file_path)
         try:
             content = source_file.read_text(encoding="utf-8")
+            # Determine target file (respects env var like --edit does)
+            env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH")
+            target_file = Path(env_path) if env_path else CUSTOM_PROMPT_FILE
             # 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}")
+            target_file.parent.mkdir(parents=True, exist_ok=True)
+            target_file.write_text(content, encoding="utf-8")
+            console.print(f"Custom prompt copied from {file_path} to {target_file}")
         except OSError as e:
             console.print(f"[red]Error reading file {file_path}: {e}[/red]")
             raise click.Abort() from e
🤖 Prompt for AI Agents
In src/gac/prompt_cli.py around lines 208 to 255, the --file branch currently
always writes the copied prompt to CUSTOM_PROMPT_FILE which ignores
GAC_SYSTEM_PROMPT_PATH and causes inconsistent behavior versus --edit; change
the --file flow to obtain the target path via _get_prompt_file_to_edit() (same
as the --edit branch), create the parent dir for that target, then write the
source file content to that target (preserving the existing OSError handling),
and keep the console messages and error handling consistent with the --edit
path.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user sets GAC_SYSTEM_PROMPT_PATH then that takes precedent. We accept that the user knows what they are doing. Should i maybe add a log for this specific case? Telling the user the env is set and therefore --file command will do nothing?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sogoiii, I understand your point about GAC_SYSTEM_PROMPT_PATH taking precedence, but there's an inconsistency issue here:

Current behavior:

  • gac prompt set --editrespects env var, writes to the path specified in GAC_SYSTEM_PROMPT_PATH if set (via _get_prompt_file_to_edit())
  • gac prompt set --fileignores env var, always writes to ~/.config/gac/custom_system_prompt.txt

This means:

export GAC_SYSTEM_PROMPT_PATH=/custom/prompt.txt

# This edits /custom/prompt.txt ✓
gac prompt set --edit

# This writes to ~/.config/gac/custom_system_prompt.txt, which won't be used ✗
gac prompt set --file source.txt

For consistency, I'd recommend:
Make both --edit and --file behave identically - both should respect GAC_SYSTEM_PROMPT_PATH. This way users get predictable behavior regardless of which option they use.

If you prefer keeping the current design:
Then yes, you should definitely add a warning or error (not just a log), something like:

env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH")
if env_path:
    console.print(f"[yellow]Warning: GAC_SYSTEM_PROMPT_PATH is set to {env_path}[/yellow]")
    console.print("[yellow]The file will be copied to ~/.config/gac/custom_system_prompt.txt but won't be used.[/yellow]")
    console.print("[yellow]Consider using --edit instead, or unset GAC_SYSTEM_PROMPT_PATH.[/yellow]")

However, I'd still recommend option 1 (consistency) as the better UX. What do you think?



@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.")
Comment on lines +259 to +266
Copy link

@coderabbitai coderabbitai bot Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, explore the repository structure
git ls-files src/ | head -20

Repository: cellwebb/gac

Length of output: 527


🏁 Script executed:

# Search for tests related to the clear command
fd -e py -path "*/test*" | xargs rg -l "clear" 2>/dev/null | head -10

Repository: cellwebb/gac

Length of output: 461


🏁 Script executed:

# Check for documentation about GAC_SYSTEM_PROMPT_PATH
rg -n "GAC_SYSTEM_PROMPT_PATH" --type=md,txt --type=py -B2 -A2

Repository: cellwebb/gac

Length of output: 86


🏁 Script executed:

# Look for the implementation of get_custom_prompt and related functions
rg -n "def get_custom_prompt|def show|GAC_SYSTEM_PROMPT_PATH" src/ -B1 -A3

Repository: cellwebb/gac

Length of output: 2705


🏁 Script executed:

# Check the clear command implementation and surrounding code
sed -n '245,280p' src/gac/prompt_cli.py

Repository: cellwebb/gac

Length of output: 952


🏁 Script executed:

# View the full set command implementation
sed -n '175,210p' src/gac/prompt_cli.py

Repository: cellwebb/gac

Length of output: 1173


🏁 Script executed:

# Check test file for clear and set command tests
cat tests/test_prompt_cli.py

Repository: cellwebb/gac

Length of output: 12591


🏁 Script executed:

# Search for any documentation or comments about the clear command and env var interaction
rg -n "clear.*env|GAC_SYSTEM_PROMPT_PATH.*clear" src/ -B2 -A2

Repository: cellwebb/gac

Length of output: 421


🏁 Script executed:

# Check if there's any documentation file that mentions this behavior
fd -type f \( -name "*.md" -o -name "*.rst" -o -name "*.txt" \) | xargs rg -l "GAC_SYSTEM_PROMPT_PATH\|clear.*prompt" 2>/dev/null

Repository: cellwebb/gac

Length of output: 228


Inconsistent behavior between set and clear commands with GAC_SYSTEM_PROMPT_PATH.

The set command (in --edit mode) respects GAC_SYSTEM_PROMPT_PATH and modifies the env-var-specified file if set, but the clear command ignores the environment variable and only deletes CUSTOM_PROMPT_FILE. This means running gac prompt clear followed by gac prompt show will still display the custom prompt from GAC_SYSTEM_PROMPT_PATH if it's set.

This inconsistency is not covered by tests. Consider either:

  1. Making clear also handle the env-var-specified file (delete or unset it)
  2. Documenting this limitation in the command help text
  3. Adding a warning if GAC_SYSTEM_PROMPT_PATH is set when running clear
🤖 Prompt for AI Agents
In src/gac/prompt_cli.py around lines 259-266, the clear command currently only
unlinks CUSTOM_PROMPT_FILE and ignores GAC_SYSTEM_PROMPT_PATH; update it to
first check for an environment-specified path (GAC_SYSTEM_PROMPT_PATH) and use
that as the target file if present, falling back to CUSTOM_PROMPT_FILE
otherwise; attempt to unlink the chosen path, print a success message if deleted
or a "no custom prompt file" message if missing, and catch/log any filesystem
errors so behavior matches the set/edit command and avoids leaving an
env-controlled prompt behind.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again the GAC_SYSTEM_PROMPT_PATH takes precedent. The env var is the original way of handling custom prompts. The new command is mean to handle new users who wont use the env var.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sogoiii, thanks for the clarification! I understand now – the env var is for power users who manage their own files, while the CLI commands are for casual users who don't use environment variables. That design rationale makes sense.

One small UX consideration: since set --edit does respect GAC_SYSTEM_PROMPT_PATH and will edit that file when set (via _get_prompt_file_to_edit()), it might be helpful to show users which file they're editing. For example:

[bold]Editing: /path/from/env/var[/bold]
Edit your custom system prompt:
[dim]Esc+Enter or Ctrl+S to save | Ctrl+C to cancel[/dim]

This would make it clear that they're editing the env-var file (which won't be affected by clear) vs the CLI-managed file. Just a thought for future improvement – not critical for this PR.


✏️ Learnings added
Learnt from: sogoiii
Repo: cellwebb/gac PR: 63
File: src/gac/prompt_cli.py:259-266
Timestamp: 2025-12-11T05:16:13.792Z
Learning: In gac CLI, GAC_SYSTEM_PROMPT_PATH environment variable is the original method for setting custom prompts and is intended for power users who manage their own prompt files. The new `gac prompt` CLI commands (set, clear, show) are designed for casual users who don't use environment variables. The clear command intentionally only deletes the CLI-managed file (~/.config/gac/custom_system_prompt.txt) and does not remove env-var-specified files, as those users are expected to manage their own files.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Loading
Loading