diff --git a/CHANGELOG.md b/CHANGELOG.md index 1226a86d..3380c16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to Memori will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### **Added** + +#### **Minimal CLI for Memori** +- `memori --version`: Display version +- `memori init [--force]`: Create starter config (`memori.json`) +- `memori health [--config ] [--check-db]`: Validate environment and configuration +- Console script automatically available after `pip install memorisdk` +- Pure Python implementation using only standard library (argparse, json, pathlib, importlib.metadata) +- 26 unit tests with comprehensive coverage + +#### **Documentation** +- Added CLI reference guide with examples and troubleshooting +- Updated quick-start to mention CLI availability +- Integrated CLI reference into navigation + +--- + ## [2.3.0] - 2025-09-29 ### 🚀 **Major Performance Improvements** diff --git a/docs/getting-started/cli-reference.md b/docs/getting-started/cli-reference.md new file mode 100644 index 00000000..9680c320 --- /dev/null +++ b/docs/getting-started/cli-reference.md @@ -0,0 +1,114 @@ +# CLI Reference + +Memori CLI is automatically available after `pip install memorisdk`. + +## Quick Reference + +| Command | Purpose | +|---------|---------| +| `memori --version` | Show version | +| `memori init` | Create `memori.json` config | +| `memori init --force` | Overwrite config | +| `memori health` | Validate setup | +| `memori health --check-db` | Check database connection | +| `memori health --config ` | Validate custom config | + +--- + +## Commands + +### `memori --version` + +Display installed version: +```bash +memori --version +# Output: memori version 2.3.0 +``` + +### `memori init` + +Create a starter configuration with sensible defaults: +```bash +memori init +memori init --force # Overwrite existing config +``` + +Creates `memori.json` with: +- SQLite database connection +- OpenAI API key placeholder +- Memory namespace and retention policy +- Logging configuration + +See [Configuration Guide](../configuration/settings.md) for detailed structure. + +### `memori health` + +Validate environment, dependencies, and configuration: +```bash +memori health +memori health --config custom.json # Validate custom config +memori health --check-db # Include database test +``` + +**Checks performed:** +1. Package import +2. Core dependencies (Pydantic, SQLAlchemy, OpenAI, LiteLLM, Loguru, python-dotenv) +3. Configuration file validity and required sections +4. Database connectivity (with `--check-db`) + +**Exit codes:** +- `0`: All checks passed +- Non-zero: At least one check failed + +--- + +## Workflow + +```bash +# 1. Create config +memori init + +# 2. Edit memori.json +# Set your OpenAI API key and adjust settings as needed + +# 3. Validate setup +memori health --check-db + +# 4. Use in Python +python your_script.py +``` + +## CI/CD Integration + +```yaml +# .github/workflows/test.yml +- name: Install dependencies + run: pip install memorisdk + +- name: Verify memori setup + run: memori health --check-db +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Command not found | `pip install memorisdk` | +| Config not found | `memori init` | +| Invalid JSON | `memori init --force` | +| Missing dependencies | `pip install memorisdk[all]` | +| Database connection error | Check `connection_string` in `memori.json` | + +--- + +## Help + +```bash +memori --help # Show all commands +memori init --help # Show init options +memori health --help # Show health options +``` + +--- + +**Next:** [Configuration Guide](../configuration/settings.md) • [Basic Usage](basic-usage.md) • [Examples](../examples/overview.md) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 06979bbe..09220e8d 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -8,6 +8,9 @@ Get Memori running in less than a minute. pip install memorisdk openai ``` +!!! tip "CLI Available" + Memori now includes a CLI! Use `memori init` to create a config file and `memori health` to check your setup. See the [CLI Reference](cli-reference.md) for details. + ## 2. Set API Key ```bash diff --git a/memori/cli.py b/memori/cli.py new file mode 100644 index 00000000..ef4bba9d --- /dev/null +++ b/memori/cli.py @@ -0,0 +1,281 @@ +""" +Memori CLI - Command-line interface for memori package + +Provides commands for initialization, version checking, and health validation. +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import Optional + + +def get_version() -> str: + """Get the memori package version.""" + try: + from importlib.metadata import version + return version("memorisdk") + except Exception: + # Fallback to __init__.py version if metadata not available + try: + from memori import __version__ + return __version__ + except ImportError: + return "unknown" + + +def cmd_version(args: argparse.Namespace) -> int: + """Handle --version command.""" + version = get_version() + print(f"memori version {version}") + return 0 + + +def cmd_init(args: argparse.Namespace) -> int: + """ + Handle init command - creates a starter memori.json config. + + Returns: + 0 on success, 1 on failure + """ + config_path = Path("memori.json") + + # Check if file exists and --force not used + if config_path.exists() and not args.force: + print(f"Error: {config_path} already exists. Use --force to overwrite.") + return 1 + + # Default configuration template + default_config = { + "database": { + "connection_string": "sqlite:///memori.db", + "pool_size": 5, + "echo_sql": False + }, + "agents": { + "openai_api_key": "sk-your-openai-key-here", + "default_model": "gpt-4o-mini", + "conscious_ingest": True, + "max_tokens": 2000, + "temperature": 0.1 + }, + "memory": { + "namespace": "default", + "retention_policy": "30_days", + "importance_threshold": 0.3, + "context_limit": 3, + "auto_cleanup": True + }, + "logging": { + "level": "INFO", + "log_to_file": False, + "structured_logging": False + } + } + + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(default_config, f, indent=2) + + action = "Overwritten" if config_path.exists() and args.force else "Created" + print(f"✓ {action} {config_path}") + print("\nNext steps:") + print(" 1. Edit memori.json with your configuration") + print(" 2. Set your OpenAI API key (or use environment variable OPENAI_API_KEY)") + print(" 3. Import and use memori in your Python code:") + print("\n from memori import Memori") + print(" memory = Memori(config_path='memori.json')") + return 0 + except Exception as e: + print(f"Error: Failed to create {config_path}: {e}") + return 1 + + +def cmd_health(args: argparse.Namespace) -> int: + """ + Handle health command - validates environment and configuration. + + Returns: + 0 if all checks pass, non-zero otherwise + """ + print("Memori Health Check") + print("=" * 50) + + exit_code = 0 + + # Check 1: Package import + print("\n1. Package Import Check...") + try: + import memori + version = get_version() + print(f" ✓ memori {version} imported successfully") + except ImportError as e: + print(f" ✗ Failed to import memori: {e}") + exit_code = 1 + + # Check 2: Core dependencies + print("\n2. Core Dependencies Check...") + required_deps = [ + ("pydantic", "Pydantic"), + ("sqlalchemy", "SQLAlchemy"), + ("openai", "OpenAI"), + ("litellm", "LiteLLM"), + ("loguru", "Loguru"), + ("dotenv", "python-dotenv") + ] + + for module_name, display_name in required_deps: + try: + __import__(module_name) + print(f" ✓ {display_name} available") + except ImportError: + print(f" ✗ {display_name} not installed") + exit_code = 1 + + # Check 3: Configuration file validation + print("\n3. Configuration File Check...") + config_path = Path(args.config if args.config else "memori.json") + + if not config_path.exists(): + print(f" ⚠ Config file not found: {config_path}") + print(f" Run 'memori init' to create a starter configuration") + else: + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # Validate required sections + required_sections = ["database", "agents", "memory", "logging"] + missing_sections = [s for s in required_sections if s not in config] + + if missing_sections: + print(f" ✗ Missing required sections: {', '.join(missing_sections)}") + exit_code = 1 + else: + print(f" ✓ Config file valid: {config_path}") + + # Check database connection string + if "database" in config and "connection_string" in config["database"]: + conn_str = config["database"]["connection_string"] + print(f" ✓ Database: {conn_str.split(':')[0]}") + + # Check namespace + if "memory" in config and "namespace" in config["memory"]: + namespace = config["memory"]["namespace"] + print(f" ✓ Namespace: {namespace}") + + except json.JSONDecodeError as e: + print(f" ✗ Invalid JSON in config file: {e}") + exit_code = 1 + except Exception as e: + print(f" ✗ Error reading config: {e}") + exit_code = 1 + + # Check 4: Database connectivity (optional, only if config exists) + if args.check_db and config_path.exists(): + print("\n4. Database Connectivity Check...") + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + conn_str = config.get("database", {}).get("connection_string") + if conn_str: + from memori.core.database import DatabaseManager + + try: + db = DatabaseManager(connection_string=conn_str) + print(f" ✓ Database connection successful") + except Exception as e: + print(f" ✗ Database connection failed: {e}") + exit_code = 1 + else: + print(f" ⚠ No connection string in config") + except Exception as e: + print(f" ✗ Database check failed: {e}") + exit_code = 1 + + # Summary + print("\n" + "=" * 50) + if exit_code == 0: + print("✓ All health checks passed!") + else: + print("✗ Some health checks failed. Please review the output above.") + + return exit_code + + +def main() -> int: + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + prog="memori", + description="Memori - The Open-Source Memory Layer for AI Agents", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + memori --version Show version information + memori init Create a starter memori.json config + memori init --force Overwrite existing memori.json + memori health Check environment and configuration + memori health --check-db Include database connectivity check + memori health --config path/to/config.json + """ + ) + + # Version flag (can be used standalone) + parser.add_argument( + "--version", + action="store_true", + help="Show version information" + ) + + # Subcommands + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # init command + init_parser = subparsers.add_parser( + "init", + help="Create a starter memori.json configuration file" + ) + init_parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing configuration file" + ) + + # health command + health_parser = subparsers.add_parser( + "health", + help="Check environment, dependencies, and configuration" + ) + health_parser.add_argument( + "--config", + type=str, + default=None, + help="Path to configuration file (default: memori.json)" + ) + health_parser.add_argument( + "--check-db", + action="store_true", + help="Include database connectivity check" + ) + + args = parser.parse_args() + + # Handle --version flag + if args.version: + return cmd_version(args) + + # Handle subcommands + if args.command == "init": + return cmd_init(args) + elif args.command == "health": + return cmd_health(args) + else: + # No command provided, show help + parser.print_help() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mkdocs.yml b/mkdocs.yml index 688848ce..5afa6909 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ nav: - Quick Start: getting-started/quick-start.md - Installation: getting-started/installation.md - Basic Usage: getting-started/basic-usage.md + - CLI Reference: getting-started/cli-reference.md - Open Source: - Features: open-source/features.md - Architecture: open-source/architecture.md diff --git a/pyproject.toml b/pyproject.toml index a8703cf8..98eb324b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,9 +119,8 @@ Repository = "https://github.com/GibsonAI/memori.git" "Changelog" = "https://github.com/GibsonAI/memori/blob/main/CHANGELOG.md" "Contributing" = "https://github.com/GibsonAI/memori/blob/main/CONTRIBUTING.md" -# [project.scripts] -# CLI not yet implemented - remove entry point until CLI is created -# memori = "memori.cli:main" +[project.scripts] +memori = "memori.cli:main" [tool.setuptools.packages.find] include = ["memori*"] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 00000000..3157c13d --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,444 @@ +""" +Unit tests for the memori CLI module + +Tests for all CLI commands: --version, init, and health +""" + +import json +import sys +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Import the CLI module +from memori.cli import cmd_health, cmd_init, cmd_version, get_version, main + + +class TestGetVersion: + """Tests for get_version function""" + + def test_get_version_returns_string(self): + """Test that get_version returns a version string""" + version = get_version() + assert isinstance(version, str) + assert len(version) > 0 + + def test_get_version_format(self): + """Test that version has expected format (x.y.z or 'unknown')""" + version = get_version() + # Should be either semver-like or 'unknown' + assert version == "unknown" or "." in version + + +class TestVersionCommand: + """Tests for --version command""" + + def test_cmd_version_output(self, capsys): + """Test that --version prints version information""" + args = MagicMock() + exit_code = cmd_version(args) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "memori version" in captured.out.lower() + + def test_version_flag_via_main(self, capsys): + """Test --version flag through main function""" + with patch.object(sys, "argv", ["memori", "--version"]): + exit_code = main() + + captured = capsys.readouterr() + assert exit_code == 0 + assert "version" in captured.out.lower() + + +class TestInitCommand: + """Tests for init command""" + + def test_init_creates_config_file(self, tmp_path, monkeypatch): + """Test that init creates memori.json""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + args = MagicMock() + args.force = False + + exit_code = cmd_init(args) + + assert exit_code == 0 + assert config_path.exists() + + def test_init_creates_valid_json(self, tmp_path, monkeypatch, capsys): + """Test that created config is valid JSON""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + args = MagicMock() + args.force = False + + cmd_init(args) + + # Verify it's valid JSON + with open(config_path, "r") as f: + config = json.load(f) + + # Check required sections + assert "database" in config + assert "agents" in config + assert "memory" in config + assert "logging" in config + + def test_init_config_has_expected_structure(self, tmp_path, monkeypatch): + """Test that config has all expected fields""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + args = MagicMock() + args.force = False + + cmd_init(args) + + with open(config_path, "r") as f: + config = json.load(f) + + # Check database section + assert "connection_string" in config["database"] + assert "pool_size" in config["database"] + + # Check agents section + assert "openai_api_key" in config["agents"] + assert "default_model" in config["agents"] + + # Check memory section + assert "namespace" in config["memory"] + assert "retention_policy" in config["memory"] + + # Check logging section + assert "level" in config["logging"] + + def test_init_fails_if_file_exists_without_force(self, tmp_path, monkeypatch, capsys): + """Test that init fails if file exists and --force not used""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + # Create existing file + config_path.write_text('{"test": "data"}') + + args = MagicMock() + args.force = False + + exit_code = cmd_init(args) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "already exists" in captured.out.lower() + + def test_init_overwrites_with_force_flag(self, tmp_path, monkeypatch, capsys): + """Test that init overwrites existing file with --force""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + # Create existing file + config_path.write_text('{"old": "data"}') + + args = MagicMock() + args.force = True + + exit_code = cmd_init(args) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "overwritten" in captured.out.lower() + + # Verify new content + with open(config_path, "r") as f: + config = json.load(f) + + assert "old" not in config + assert "database" in config + + def test_init_prints_next_steps(self, tmp_path, monkeypatch, capsys): + """Test that init prints helpful next steps""" + monkeypatch.chdir(tmp_path) + + args = MagicMock() + args.force = False + + cmd_init(args) + + captured = capsys.readouterr() + assert "next steps" in captured.out.lower() + assert "from memori import" in captured.out.lower() + + +class TestHealthCommand: + """Tests for health command""" + + def test_health_basic_check(self, capsys): + """Test basic health check without config file""" + args = MagicMock() + args.config = None + args.check_db = False + + # Should not fail even without config + exit_code = cmd_health(args) + + captured = capsys.readouterr() + assert "health check" in captured.out.lower() + assert "package import" in captured.out.lower() + + def test_health_checks_package_import(self, capsys): + """Test that health checks package import""" + args = MagicMock() + args.config = None + args.check_db = False + + cmd_health(args) + + captured = capsys.readouterr() + # Should show successful import + assert "memori" in captured.out.lower() + assert "✓" in captured.out or "success" in captured.out.lower() + + def test_health_checks_dependencies(self, capsys): + """Test that health checks for required dependencies""" + args = MagicMock() + args.config = None + args.check_db = False + + cmd_health(args) + + captured = capsys.readouterr() + # Should check for core dependencies + assert "dependencies" in captured.out.lower() + output_lower = captured.out.lower() + # At least some common dependencies should be mentioned + assert any( + dep in output_lower + for dep in ["pydantic", "sqlalchemy", "openai", "litellm"] + ) + + def test_health_warns_if_no_config(self, tmp_path, monkeypatch, capsys): + """Test that health warns if config file not found""" + monkeypatch.chdir(tmp_path) + + args = MagicMock() + args.config = None + args.check_db = False + + cmd_health(args) + + captured = capsys.readouterr() + assert "not found" in captured.out.lower() or "⚠" in captured.out + + def test_health_validates_config_if_exists(self, tmp_path, monkeypatch, capsys): + """Test that health validates config file if it exists""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + # Create valid config + valid_config = { + "database": {"connection_string": "sqlite:///test.db"}, + "agents": {"default_model": "gpt-4o-mini"}, + "memory": {"namespace": "test"}, + "logging": {"level": "INFO"}, + } + + with open(config_path, "w") as f: + json.dump(valid_config, f) + + args = MagicMock() + args.config = None + args.check_db = False + + exit_code = cmd_health(args) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "config file valid" in captured.out.lower() or "✓" in captured.out + + def test_health_detects_invalid_json(self, tmp_path, monkeypatch, capsys): + """Test that health detects invalid JSON in config""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + # Create invalid JSON + config_path.write_text("{invalid json content") + + args = MagicMock() + args.config = None + args.check_db = False + + exit_code = cmd_health(args) + + captured = capsys.readouterr() + assert exit_code != 0 + assert "invalid" in captured.out.lower() or "✗" in captured.out + + def test_health_detects_missing_sections(self, tmp_path, monkeypatch, capsys): + """Test that health detects missing required config sections""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + + # Create config missing required sections + incomplete_config = {"database": {"connection_string": "sqlite:///test.db"}} + + with open(config_path, "w") as f: + json.dump(incomplete_config, f) + + args = MagicMock() + args.config = None + args.check_db = False + + exit_code = cmd_health(args) + + captured = capsys.readouterr() + assert exit_code != 0 + assert "missing" in captured.out.lower() or "✗" in captured.out + + def test_health_with_custom_config_path(self, tmp_path, monkeypatch, capsys): + """Test health with custom config path""" + monkeypatch.chdir(tmp_path) + custom_config = tmp_path / "custom_config.json" + + # Create valid config at custom path + valid_config = { + "database": {"connection_string": "sqlite:///test.db"}, + "agents": {"default_model": "gpt-4o-mini"}, + "memory": {"namespace": "test"}, + "logging": {"level": "INFO"}, + } + + with open(custom_config, "w") as f: + json.dump(valid_config, f) + + args = MagicMock() + args.config = str(custom_config) + args.check_db = False + + exit_code = cmd_health(args) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "custom_config.json" in captured.out + + def test_health_shows_summary(self, capsys): + """Test that health shows a summary at the end""" + args = MagicMock() + args.config = None + args.check_db = False + + cmd_health(args) + + captured = capsys.readouterr() + # Should show summary with pass/fail status + assert "=" in captured.out # Separator lines + output_lower = captured.out.lower() + assert "passed" in output_lower or "failed" in output_lower + + +class TestMainFunction: + """Tests for main CLI entry point""" + + def test_main_no_args_shows_help(self, capsys): + """Test that running with no args shows help""" + with patch.object(sys, "argv", ["memori"]): + exit_code = main() + + captured = capsys.readouterr() + assert exit_code == 0 + # Should show usage/help + assert "usage:" in captured.out.lower() or "memori" in captured.out.lower() + + def test_main_init_command(self, tmp_path, monkeypatch, capsys): + """Test init command through main""" + monkeypatch.chdir(tmp_path) + + with patch.object(sys, "argv", ["memori", "init"]): + exit_code = main() + + assert exit_code == 0 + assert (tmp_path / "memori.json").exists() + + def test_main_init_with_force(self, tmp_path, monkeypatch, capsys): + """Test init --force through main""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "memori.json" + config_path.write_text("{}") + + with patch.object(sys, "argv", ["memori", "init", "--force"]): + exit_code = main() + + captured = capsys.readouterr() + assert exit_code == 0 + assert "overwritten" in captured.out.lower() + + def test_main_health_command(self, capsys): + """Test health command through main""" + with patch.object(sys, "argv", ["memori", "health"]): + exit_code = main() + + captured = capsys.readouterr() + assert "health check" in captured.out.lower() + + def test_main_health_with_flags(self, tmp_path, monkeypatch, capsys): + """Test health with --config and --check-db flags""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "test.json" + + # Create minimal valid config + with open(config_path, "w") as f: + json.dump( + { + "database": {}, + "agents": {}, + "memory": {}, + "logging": {}, + }, + f, + ) + + with patch.object( + sys, "argv", ["memori", "health", "--config", str(config_path)] + ): + exit_code = main() + + captured = capsys.readouterr() + assert "test.json" in captured.out + + +class TestCLIIntegration: + """Integration tests for complete CLI workflows""" + + def test_full_workflow_init_and_health(self, tmp_path, monkeypatch, capsys): + """Test complete workflow: init then health check""" + monkeypatch.chdir(tmp_path) + + # Step 1: Initialize + with patch.object(sys, "argv", ["memori", "init"]): + init_exit = main() + + assert init_exit == 0 + + # Step 2: Health check + with patch.object(sys, "argv", ["memori", "health"]): + health_exit = main() + + captured = capsys.readouterr() + assert health_exit == 0 + assert "✓" in captured.out or "passed" in captured.out.lower() + + def test_version_displays_correctly(self, capsys): + """Test that version command displays properly""" + with patch.object(sys, "argv", ["memori", "--version"]): + exit_code = main() + + captured = capsys.readouterr() + assert exit_code == 0 + assert "memori version" in captured.out.lower() + # Should have actual version number or 'unknown' + assert any( + c.isdigit() for c in captured.out + ) or "unknown" in captured.out.lower()