Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
36 changes: 32 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,31 @@ def main(
console.print()
return

domain_source_count = sum(
bool(source) for source in (domain, ontology_file, custom_domain)
)
if domain_source_count > 1:
console.print(
"[red]Error:[/red] --domain, --ontology-file, and --custom-domain "
"are mutually exclusive."
)
raise SystemExit(1)
Comment thread
unshDee marked this conversation as resolved.
Outdated

# 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
console.print(f"[red]Error:[/red] Failed to load ontology file: {e}")
raise SystemExit(1)
Comment thread
unshDee marked this conversation as resolved.
Outdated

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 +396,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 +416,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
21 changes: 21 additions & 0 deletions src/create_context_graph/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,27 @@ def load_domain(domain_id: str) -> DomainOntology:
domains_dir = _get_domains_path()
domain_path = domains_dir / f"{domain_id}.yaml"

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:
data = yaml.safe_load(f)
except Exception:
continue
Comment thread
unshDee marked this conversation as resolved.
if (
isinstance(data, dict)
and data.get("domain", {}).get("id") == domain_id
):
Comment on lines +263 to +266
domain_path = path
break
Comment thread
unshDee marked this conversation as resolved.

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

Expand Down
49 changes: 49 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Integration tests for the CLI module."""

import json
from pathlib import Path

import pytest

Expand Down Expand Up @@ -76,6 +77,54 @@ 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 = Path("src/create_context_graph/domains/healthcare.yaml")
Comment thread
unshDee marked this conversation as resolved.
Outdated
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 (
(out / "data" / "ontology.yaml").read_text()
== ontology_file.read_text()
)
Comment thread
unshDee marked this conversation as resolved.
Outdated

def test_ontology_file_auto_generates_project_name(self, runner, tmp_path):
ontology_file = Path("src/create_context_graph/domains/healthcare.yaml")
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 = Path("src/create_context_graph/domains/healthcare.yaml")
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 == 1
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