Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ Arguments:

Options:
--domain TEXT Domain ID (e.g., healthcare, gaming)
--ontology-file PATH Path to a custom domain ontology YAML file
--framework TEXT Agent framework (strands [default], pydanticai, claude-agent-sdk, openai-agents, langgraph, crewai, google-adk, anthropic-tools)
--self-hosted Use self-hosted Neo4j (bolt) instead of NAMS hosted memory
--nams-api-key TEXT NAMS API key [env: MEMORY_API_KEY] — obtain at https://memory.neo4jlabs.com
Expand Down
35 changes: 31 additions & 4 deletions src/create_context_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ def _run_import_preview(
type=str,
help="Domain ID (e.g., financial-services, healthcare, software-engineering)",
)
@click.option(
"--ontology-file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Path to a custom domain ontology YAML file",
)
@click.option(
"--framework",
type=click.Choice(SUPPORTED_FRAMEWORKS, case_sensitive=False),
Expand Down Expand Up @@ -199,6 +204,7 @@ def _run_import_preview(
def main(
project_name: str | None,
domain: str | None,
ontology_file: Path | None,
framework: str | None,
demo_data: bool,
ingest: bool,
Expand Down Expand Up @@ -315,9 +321,30 @@ def main(
console.print()
return

domain_source_count = sum(
bool(source) for source in (domain, ontology_file, custom_domain)
)
if domain_source_count > 1:
raise click.UsageError(
"--domain, --ontology-file, and --custom-domain are mutually exclusive."
)

# Handle custom domain generation (non-interactive)
custom_domain_yaml = None
custom_ontology = None
if ontology_file:
from create_context_graph.ontology import load_domain_from_path

try:
custom_domain_yaml = ontology_file.read_text()
custom_ontology = load_domain_from_path(ontology_file)
Comment on lines +338 to +340
except Exception as e: # noqa: BLE001 — surface YAML/schema errors to the user
raise click.ClickException(
f"Failed to load ontology file {ontology_file}: {e}"
) from e

domain = custom_ontology.domain.id

if custom_domain:
if not anthropic_api_key:
console.print("[red]Error:[/red] --anthropic-api-key is required for custom domain generation.")
Expand Down Expand Up @@ -368,16 +395,16 @@ def main(
raise SystemExit(1)

# Auto-generate project name when all required flags are provided but no positional arg
if not project_name and (domain or custom_domain) and framework:
if not project_name and (domain or custom_domain or ontology_file) and framework:
domain_part = domain or "custom"
project_name = f"{domain_part}-{framework}-app"

# Non-TTY detection: give a helpful error when wizard would be required but stdin isn't interactive
import sys
if not project_name and not sys.stdin.isatty():
missing = []
if not domain and not custom_domain:
missing.append("--domain")
if not domain and not custom_domain and not ontology_file:
missing.append("--domain, --ontology-file, or --custom-domain")
if not framework:
missing.append("--framework")
console.print(f"[red]Error:[/red] Non-interactive mode requires: {', '.join(missing or ['--domain and --framework'])}")
Expand All @@ -388,7 +415,7 @@ def main(
# If all required args are provided (and a backend is determinable), skip wizard.
# In non-interactive mode with default NAMS backend, --nams-api-key (or
# MEMORY_API_KEY env) must be set unless --self-hosted is specified.
if project_name and (domain or custom_domain) and (framework or DEFAULT_FRAMEWORK):
if project_name and (domain or custom_domain or ontology_file) and (framework or DEFAULT_FRAMEWORK):
# Skip the credential gate during --dry-run so users can preview a
# scaffold without first signing up for a NAMS API key.
if not dry_run and memory_backend_resolved == "nams" and not nams_api_key:
Expand Down
32 changes: 30 additions & 2 deletions src/create_context_graph/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
from __future__ import annotations

from importlib.resources import files
import logging
from pathlib import Path

import yaml
from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Pydantic models for the ontology YAML schema
Expand Down Expand Up @@ -240,12 +243,37 @@ def load_domain(domain_id: str) -> DomainOntology:
"""Load a domain ontology by ID, merging with base definitions."""
domains_dir = _get_domains_path()
domain_path = domains_dir / f"{domain_id}.yaml"
data = None

if not domain_path.exists():
custom_dir = _get_custom_domains_path()
custom_path = custom_dir / f"{domain_id}.yaml"
if custom_path.exists():
domain_path = custom_path
elif custom_dir.exists():
for path in sorted(custom_dir.glob("*.yaml")):
if path.stem.startswith("_"):
continue
try:
with open(path) as f:
candidate_data = yaml.safe_load(f)
except (OSError, yaml.YAMLError) as exc:
logger.debug("Skipping invalid custom domain YAML %s: %s", path, exc)
continue
Comment thread
unshDee marked this conversation as resolved.
if (
isinstance(candidate_data, dict)
and candidate_data.get("domain", {}).get("id") == domain_id
):
Comment on lines +263 to +266
domain_path = path
data = candidate_data
break
Comment thread
unshDee marked this conversation as resolved.

if not domain_path.exists():
raise FileNotFoundError(f"Domain ontology not found: {domain_id}")

with open(domain_path) as f:
data = yaml.safe_load(f)
if data is None:
with open(domain_path) as f:
data = yaml.safe_load(f)

# Merge base if domain declares inheritance
if data.get("inherits") == "_base" or data.get("domain", {}).get("inherits") == "_base":
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
"""Integration tests for the CLI module."""

import json
from pathlib import Path

import pytest
import yaml

from create_context_graph.cli import main


# The ``runner`` and ``nams_runner`` fixtures live in tests/conftest.py so they
# can be shared with test_matrix.py, test_performance.py, and others.

REPO_ROOT = Path(__file__).resolve().parents[1]
HEALTHCARE_ONTOLOGY = REPO_ROOT / "src" / "create_context_graph" / "domains" / "healthcare.yaml"
Comment on lines +29 to +30


class TestListDomains:
def test_list_domains(self, runner):
Expand Down Expand Up @@ -76,6 +81,53 @@ def test_scaffold_with_demo_data(self, runner, tmp_path):
data = json.loads(fixture.read_text())
assert len(data["entities"]) > 0

def test_scaffold_with_ontology_file(self, runner, tmp_path):
ontology_file = HEALTHCARE_ONTOLOGY
out = tmp_path / "ontology-app"

result = runner.invoke(main, [
"ontology-app",
"--ontology-file", str(ontology_file),
"--framework", "pydanticai",
"--output-dir", str(out),
])

assert result.exit_code == 0, result.output
assert (out / "backend" / "app" / "main.py").exists()
assert (out / "frontend" / "package.json").exists()
assert yaml.safe_load((out / "data" / "ontology.yaml").read_text()) == yaml.safe_load(
ontology_file.read_text()
)

def test_ontology_file_auto_generates_project_name(self, runner, tmp_path):
ontology_file = HEALTHCARE_ONTOLOGY
out = tmp_path / "dry-run-app"

result = runner.invoke(main, [
"--ontology-file", str(ontology_file),
"--framework", "pydanticai",
"--output-dir", str(out),
"--dry-run",
])

assert result.exit_code == 0, result.output
assert "healthcare-pydanticai-app" in result.output

def test_ontology_file_is_mutually_exclusive_with_domain(self, runner, tmp_path):
ontology_file = HEALTHCARE_ONTOLOGY
out = tmp_path / "my-app"

result = runner.invoke(main, [
"my-app",
"--domain", "healthcare",
"--ontology-file", str(ontology_file),
"--framework", "pydanticai",
"--output-dir", str(out),
])

assert result.exit_code == 2
assert "mutually exclusive" in result.output

def test_invalid_domain(self, runner, tmp_path):
out = tmp_path / "my-app"
result = runner.invoke(main, [
Expand Down
16 changes: 16 additions & 0 deletions tests/test_custom_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from create_context_graph.ontology import (
DomainOntology,
list_available_domains,
load_domain,
load_domain_from_yaml_string,
)

Expand Down Expand Up @@ -479,3 +480,18 @@ def test_list_includes_saved_custom(self, tmp_path):
domains = list_available_domains()
ids = [d["id"] for d in domains]
assert "test-domain" in ids

def test_load_domain_finds_saved_custom_by_domain_id(self, tmp_path):
"""Saved custom domains can be loaded by the ID declared in YAML."""
custom_dir = tmp_path / "custom-domains"
custom_dir.mkdir()
(custom_dir / "my-custom.yaml").write_text(VALID_DOMAIN_YAML)

with patch(
"create_context_graph.ontology._get_custom_domains_path",
return_value=custom_dir,
):
ontology = load_domain("test-domain")

assert ontology.domain.id == "test-domain"
assert ontology.domain.name == "Test Domain"
Loading