Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
19e5286
feat(cli): add support for Goose CLI in mcp setup
DhineshPonnarasan Feb 11, 2026
575eef9
fix: address PR review comments (indentation, type safety, directory …
DhineshPonnarasan Feb 13, 2026
f2700fb
feat(cli): add project level configuration management
DhineshPonnarasan Feb 13, 2026
ef7fc3e
fix: resolve merge conflict, support Goose and Antigravity in IDE setup
DhineshPonnarasan Feb 20, 2026
459351c
fix: resolve merge conflicts, indentation, and syntax errors; ensure …
DhineshPonnarasan Feb 21, 2026
7eb8d39
test: ensure pyproject.toml exists in test project before indexing
DhineshPonnarasan Feb 21, 2026
0188ada
test: add explicit pyproject.toml config for index and delete tests
DhineshPonnarasan Feb 21, 2026
368caac
Fix Neo4j config, Goose CLI support, merge conflicts, and test setup
DhineshPonnarasan Feb 21, 2026
8b98b82
Add Neo4j startup step to CI workflow
DhineshPonnarasan Feb 21, 2026
5233261
Add config copy step to CI workflow for tests
DhineshPonnarasan Feb 21, 2026
456a711
Write full config to temp test directories for CI reliability
DhineshPonnarasan Feb 21, 2026
398c9d2
Write only Neo4j config to temp test directories for CI reliability
DhineshPonnarasan Feb 21, 2026
c252b19
Fix: CLI loads Neo4j config from pyproject.toml, tests use .venv Pyth…
DhineshPonnarasan Feb 21, 2026
c9929d3
Fix: test helper uses .venv/bin/python on Linux, .venv/Scripts/python…
DhineshPonnarasan Feb 21, 2026
04a9461
Fix: test helper falls back to sys.executable if venv Python does not…
DhineshPonnarasan Feb 21, 2026
51e22f7
CI: install toml package for end-to-end tests
DhineshPonnarasan Feb 21, 2026
a1a2321
CI: Add Neo4j service container for E2E tests
DhineshPonnarasan Feb 21, 2026
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
25 changes: 24 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ on:
jobs:
test:
runs-on: ubuntu-latest

services:
neo4j:
image: neo4j:5
ports:
- 7687:7687
env:
NEO4J_AUTH: "neo4j/neo4jpassword"
options: >
--health-cmd "cypher-shell -u neo4j -p neo4jpassword 'RETURN 1' || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Check out code
uses: actions/checkout@v3
Expand All @@ -24,6 +35,18 @@ jobs:
python -m pip install --upgrade pip
pip install -e .
pip install pytest
pip install toml

- name: Add default config for tests
run: cp pyproject.toml tests/fixtures/sample_projects/pyproject.toml

- name: Wait for Neo4j to be ready
run: |
for i in {1..30}; do
nc -z localhost 7687 && echo "Neo4j is up" && break
echo "Waiting for Neo4j..."
sleep 2
done

- name: Run end-to-end tests
run: |
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ markers = [
"e2e: mark a test as an end-to-end test.",
"slow: mark test as slow."
]

[tool.codegraphcontext]
database = 'Neo4j'
neo4j_uri = 'bolt://localhost:7687'
neo4j_username = 'neo4j'
neo4j_password = 'neo4jpassword'
46 changes: 44 additions & 2 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def mcp_setup():
Sets up CodeGraphContext integration with your IDE or CLI tool:
- VS Code, Cursor, Windsurf
- Claude Desktop, Gemini CLI
- Cline, RooCode, Amazon Q Developer
- Cline, RooCode, Amazon Q Developer, Goose

Works with FalkorDB by default (no database setup needed).
"""
Expand Down Expand Up @@ -238,7 +238,49 @@ def _load_credentials():
config_sources.append(dotenv_values(dotenv_path))
config_source_names.append(str(dotenv_path))
except Exception as e:
console.print(f"[yellow]Warning: Could not load .env from current directory: {e}[/yellow]")
console.print(f"[yellow]Warning: Could not load .env from current directory: {e}[yellow]")

# 2.5. Local pyproject.toml (project directory)
import toml
project_pyproject = None
# Try to get the path argument from the stack (index/delete commands)
import inspect
frame = inspect.currentframe()
while frame:
args = frame.f_locals
if 'path' in args and args['path']:
candidate = Path(args['path'])
if candidate.is_dir():
pyproject_path = candidate / "pyproject.toml"
else:
pyproject_path = candidate.parent / "pyproject.toml"
if pyproject_path.exists():
project_pyproject = pyproject_path
break
frame = frame.f_back
if not project_pyproject:
# fallback to cwd
pyproject_path = Path.cwd() / "pyproject.toml"
if pyproject_path.exists():
project_pyproject = pyproject_path
if project_pyproject:
try:
pyproject_data = toml.load(project_pyproject)
tool_cfg = pyproject_data.get("tool", {}).get("codegraphcontext", {})
if tool_cfg:
db_env = {}
if "database" in tool_cfg:
db_env["DEFAULT_DATABASE"] = tool_cfg["database"].lower()
if "neo4j_uri" in tool_cfg:
db_env["NEO4J_URI"] = tool_cfg["neo4j_uri"]
if "neo4j_username" in tool_cfg:
db_env["NEO4J_USERNAME"] = tool_cfg["neo4j_username"]
if "neo4j_password" in tool_cfg:
db_env["NEO4J_PASSWORD"] = tool_cfg["neo4j_password"]
config_sources.append(db_env)
config_source_names.append(str(project_pyproject))
except Exception as e:
console.print(f"[yellow]Warning: Could not load pyproject.toml: {e}[yellow]")

# 1. Local mcp.json (highest priority - explicit MCP server config)
mcp_file_path = Path.cwd() / "mcp.json"
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
Loading
Loading