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
94 changes: 94 additions & 0 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from codegraphcontext.server import MCPServer
from .setup_wizard import run_neo4j_setup_wizard, configure_mcp_client
from . import config_manager
from . import project_config
# Import the new helper functions
from .cli_helpers import (
index_helper,
Expand Down Expand Up @@ -600,6 +601,99 @@ def config_db(backend: str = typer.Argument(..., help="Database backend: 'neo4j'

console.print(f"[green]✔ Default database switched to {backend}[/green]")

# ============================================================================
# PROMPT COMMAND GROUP - Custom LLM Prompts
# ============================================================================

prompt_app = typer.Typer(help="Manage custom LLM prompt files")
app.add_typer(prompt_app, name="prompt")

@prompt_app.command("add")
def prompt_add(
path: str = typer.Argument(..., help="Path to the prompt file to register")
):
"""
Add a custom prompt file to the project.

Registers a prompt file that will be injected into the LLM system prompt.
The file path is stored relative to the project root.

Examples:
cgc prompt add skills.md
cgc prompt add docs/custom-instructions.txt
cgc prompt add /absolute/path/to/prompt.md
"""
try:
success = project_config.add_prompt_file(path)
if not success:
raise typer.Exit(code=1)
except Exception as e:
console.print(f"[red]Error adding prompt file: {e}[/red]")
raise typer.Exit(code=1)

@prompt_app.command("list")
def prompt_list():
"""
List all registered prompt files.

Shows all custom prompt files that will be injected into the LLM system prompt.
Files are shown in the order they will be prepended.
"""
try:
prompts = project_config.list_prompt_files()

if not prompts:
console.print("[yellow]No custom prompt files registered.[/yellow]")
console.print("\nUse [cyan]cgc prompt add <path>[/cyan] to register a prompt file.")
return

console.print("[bold cyan]Registered Prompt Files:[/bold cyan]\n")

table = Table(show_header=True, header_style="bold magenta")
table.add_column("#", style="dim", width=3)
table.add_column("Path", style="green")
table.add_column("Status", style="cyan", width=10)

project_root = project_config.get_project_root()
for i, prompt_path in enumerate(prompts, 1):
# Check if file exists
prompt_file = Path(prompt_path)
if not prompt_file.is_absolute():
prompt_file = project_root / prompt_file

status = "✅ Found" if prompt_file.exists() else "⚠️ Missing"
table.add_row(str(i), prompt_path, status)

console.print(table)

config_file = project_config.get_project_config_file()
console.print(f"\n[dim]Config: {config_file}[/dim]")

except Exception as e:
console.print(f"[red]Error listing prompt files: {e}[/red]")
raise typer.Exit(code=1)

@prompt_app.command("remove")
def prompt_remove(
path: str = typer.Argument(..., help="Path to the prompt file to unregister")
):
"""
Remove a custom prompt file from the project.

Unregisters a prompt file so it will no longer be injected into the LLM system prompt.

Examples:
cgc prompt remove skills.md
cgc prompt remove docs/custom-instructions.txt
"""
try:
success = project_config.remove_prompt_file(path)
if not success:
raise typer.Exit(code=1)
except Exception as e:
console.print(f"[red]Error removing prompt file: {e}[/red]")
raise typer.Exit(code=1)

# ============================================================================
# BUNDLE COMMAND GROUP - Pre-indexed Graph Snapshots
# ============================================================================
Expand Down
218 changes: 218 additions & 0 deletions src/codegraphcontext/cli/project_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""
Project-level configuration management for CodeGraphContext.
Handles managing .cgc directory and project-specific settings like custom prompts.
"""

import json
import logging
from pathlib import Path
from typing import List, Optional, Dict, Any
from rich.console import Console

console = Console()
logger = logging.getLogger(__name__)

# Project config directory and file
PROJECT_CONFIG_DIR_NAME = ".cgc"
PROJECT_CONFIG_FILE_NAME = "config.json"


def get_project_root() -> Path:
"""Get the current working directory as project root."""
return Path.cwd()


def get_project_config_dir(project_root: Optional[Path] = None) -> Path:
"""Get the .cgc directory path for the project."""
if project_root is None:
project_root = get_project_root()
return project_root / PROJECT_CONFIG_DIR_NAME


def get_project_config_file(project_root: Optional[Path] = None) -> Path:
"""Get the config.json file path within .cgc directory."""
return get_project_config_dir(project_root) / PROJECT_CONFIG_FILE_NAME


def ensure_project_config_dir(project_root: Optional[Path] = None) -> Path:
"""Ensure .cgc directory exists and return its path."""
config_dir = get_project_config_dir(project_root)
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir


def load_project_config(project_root: Optional[Path] = None) -> Dict[str, Any]:
"""
Load project configuration from .cgc/config.json.
Returns an empty config structure if file doesn't exist.
"""
config_file = get_project_config_file(project_root)

if not config_file.exists():
return {"prompts": []}

try:
with open(config_file, "r", encoding="utf-8") as f:
config = json.load(f)
# Ensure prompts field exists
if "prompts" not in config:
config["prompts"] = []
return config
except json.JSONDecodeError as e:
console.print(f"[yellow]Warning: Invalid JSON in {config_file}: {e}[/yellow]")
return {"prompts": []}
except Exception as e:
console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
return {"prompts": []}


def save_project_config(config: Dict[str, Any], project_root: Optional[Path] = None):
"""Save project configuration to .cgc/config.json."""
ensure_project_config_dir(project_root)
config_file = get_project_config_file(project_root)

try:
with open(config_file, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except Exception as e:
console.print(f"[red]Error saving project config: {e}[/red]")
raise


def add_prompt_file(prompt_path: str, project_root: Optional[Path] = None) -> bool:
"""
Add a custom prompt file to the project configuration.
Validates that the file exists and prevents duplicates.
Stores paths relative to project root.

Returns True if successful, False otherwise.
"""
if project_root is None:
project_root = get_project_root()

# Convert to Path object and resolve
prompt_file = Path(prompt_path)

# Check if file exists
if not prompt_file.exists():
console.print(f"[red]❌ File not found: {prompt_path}[/red]")
return False

if not prompt_file.is_file():
console.print(f"[red]❌ Not a file: {prompt_path}[/red]")
return False

# Make path relative to project root if it's absolute and within project
try:
if prompt_file.is_absolute():
try:
relative_path = prompt_file.relative_to(project_root)
prompt_path_str = str(relative_path).replace("\\", "/")
except ValueError:
# File is outside project root, store as absolute
prompt_path_str = str(prompt_file.resolve()).replace("\\", "/")
else:
prompt_path_str = str(prompt_file).replace("\\", "/")
except Exception:
prompt_path_str = str(prompt_file).replace("\\", "/")

# Load current config
config = load_project_config(project_root)

# Check for duplicates
if prompt_path_str in config["prompts"]:
console.print(f"[yellow]⚠ Prompt file already registered: {prompt_path_str}[/yellow]")
return False

# Add to config
config["prompts"].append(prompt_path_str)
save_project_config(config, project_root)

console.print(f"[green]✅ Added prompt file: {prompt_path_str}[/green]")
return True


def list_prompt_files(project_root: Optional[Path] = None) -> List[str]:
"""
Get list of registered prompt files.
Returns list of prompt file paths.
"""
config = load_project_config(project_root)
return config.get("prompts", [])


def remove_prompt_file(prompt_path: str, project_root: Optional[Path] = None) -> bool:
"""
Remove a prompt file from the project configuration.

Returns True if successful, False otherwise.
"""
if project_root is None:
project_root = get_project_root()

# Normalize the path for comparison
prompt_file = Path(prompt_path)

# Try both relative and absolute versions
try:
if prompt_file.is_absolute():
try:
relative_path = prompt_file.relative_to(project_root)
prompt_path_normalized = str(relative_path).replace("\\", "/")
except ValueError:
prompt_path_normalized = str(prompt_file.resolve()).replace("\\", "/")
else:
prompt_path_normalized = str(prompt_file).replace("\\", "/")
except Exception:
prompt_path_normalized = str(prompt_file).replace("\\", "/")

# Load current config
config = load_project_config(project_root)

# Try to remove - check both the normalized path and original
original_length = len(config["prompts"])
config["prompts"] = [p for p in config["prompts"] if p not in (prompt_path, prompt_path_normalized)]

if len(config["prompts"]) == original_length:
console.print(f"[yellow]⚠ Prompt file not found in config: {prompt_path}[/yellow]")
return False

save_project_config(config, project_root)
console.print(f"[green]✅ Removed prompt file: {prompt_path}[/green]")
return True


def get_prompt_file_contents(project_root: Optional[Path] = None) -> List[str]:
"""
Load contents of all registered prompt files.
Skips files that don't exist and logs warnings.

Returns list of prompt file contents in order.
"""
if project_root is None:
project_root = get_project_root()

prompt_files = list_prompt_files(project_root)
contents = []

for prompt_path in prompt_files:
# Resolve path relative to project root
prompt_file = Path(prompt_path)
if not prompt_file.is_absolute():
prompt_file = project_root / prompt_file

# Try to read file
try:
if not prompt_file.exists():
logger.warning(f"Skipping missing prompt file: {prompt_path}")
continue

with open(prompt_file, "r", encoding="utf-8") as f:
content = f.read().strip()
if content:
contents.append(content)
except Exception as e:
logger.warning(f"Error reading prompt file {prompt_path}: {e}")
continue

return contents
52 changes: 51 additions & 1 deletion src/codegraphcontext/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
provided by this MCP server.
"""

from pathlib import Path
from typing import Optional
import logging

logger = logging.getLogger(__name__)

LLM_SYSTEM_PROMPT = """# AI Pair Programmer Instructions

## 1. Your Role and Goal
Expand Down Expand Up @@ -122,4 +128,48 @@
2. **Identify Failure:** If the standard tools cannot answer a complex, multi-step relationship query (e.g., "Find all functions that are called by a method in a class that inherits from 'BaseHandler'"), then and only then, resort to the fallback.
3. **Formulate & Execute:** Construct a Cypher query to find the answer and execute it using `execute_cypher_query`. **Consult the Graph Schema Reference above to ensure you use the correct property names (e.g. `path` vs `path`).**
4. **Present Results:** Explain the results to the user based on the query output.
"""
"""


def build_system_prompt(project_root: Optional[Path] = None) -> str:
"""
Build the complete system prompt by prepending custom prompt files.

Args:
project_root: Root directory of the project. If None, uses current directory.

Returns:
Complete system prompt with custom prompts prepended to the base prompt.
"""
# Import here to avoid circular dependencies
try:
from codegraphcontext.cli.project_config import get_prompt_file_contents

# Get custom prompt contents
custom_prompts = get_prompt_file_contents(project_root)

if not custom_prompts:
# No custom prompts, return base prompt
return LLM_SYSTEM_PROMPT

# Build combined prompt: custom prompts first, then base prompt
combined_parts = []

# Add custom prompts
for i, custom_content in enumerate(custom_prompts, 1):
combined_parts.append(f"# Custom Instructions {i}\n\n{custom_content}")

# Add separator and base prompt
combined_parts.append("---\n")
combined_parts.append(LLM_SYSTEM_PROMPT)

return "\n\n".join(combined_parts)

except ImportError:
# If project_config is not available (shouldn't happen in normal use)
logger.warning("Could not import project_config module, using base prompt only")
return LLM_SYSTEM_PROMPT
except Exception as e:
# Log error but continue with base prompt to maintain backward compatibility
logger.warning(f"Error loading custom prompts: {e}. Using base prompt only.")
return LLM_SYSTEM_PROMPT
Loading
Loading