import pytest from click.testing import CliRunner from pyra.cli import main def test_memory_write_creates_file(tmp_pyra_home): runner = CliRunner() result = runner.invoke(main, ["memory", "write", "user/note.md", "Hello world"]) assert result.exit_code == 0 assert (tmp_pyra_home / "memory" / "user" / "note.md").exists() assert "Hello world" in (tmp_pyra_home / "memory" / "user" / "note.md").read_text() def test_memory_write_updates_db(tmp_pyra_home): runner = CliRunner() runner.invoke(main, ["memory", "write", "user/note.md", "DB test content"]) from pyra.memory import database rows = database.list_all() assert any(r["path"] == "user/note.md" for r in rows) def test_memory_append_adds_content(tmp_pyra_home): runner = CliRunner() runner.invoke(main, ["memory", "write", "user/note.md", "First line"]) runner.invoke(main, ["memory", "append", "user/note.md", "Second line"]) content = (tmp_pyra_home / "memory" / "user" / "note.md").read_text() assert "First line" in content assert "Second line" in content def test_memory_read_existing(tmp_pyra_home): from pyra.memory.writer import write_memory write_memory("user/note.md", "Readable content") runner = CliRunner() result = runner.invoke(main, ["memory", "read", "user/note.md"]) assert result.exit_code == 0 def test_memory_read_missing_exits_cleanly(tmp_pyra_home): runner = CliRunner() result = runner.invoke(main, ["memory", "read", "user/does_not_exist.md"]) assert result.exit_code == 0 def test_memory_read_blocked_path_exits_cleanly(tmp_pyra_home): runner = CliRunner() result = runner.invoke(main, ["memory", "read", "../../../../vault/secrets/api_keys.json"]) assert result.exit_code == 0 def test_memory_list_empty(tmp_pyra_home): runner = CliRunner() result = runner.invoke(main, ["memory", "list"]) assert result.exit_code == 0 def test_memory_list_populated(tmp_pyra_home): from pyra.memory.writer import write_memory write_memory("user/profile.md", "# Profile") runner = CliRunner() result = runner.invoke(main, ["memory", "list"]) assert result.exit_code == 0 def test_plugin_list_no_config(tmp_pyra_home): runner = CliRunner() result = runner.invoke(main, ["plugin", "list"]) assert result.exit_code == 0 def _make_config(): from pyra.config.schema import PyraConfig, ProviderConfig return PyraConfig(ai=ProviderConfig(provider_id="lmstudio", model="gemma")) def test_plugin_enable_not_installed(tmp_pyra_home): from pyra.config.manager import save_config save_config(_make_config()) runner = CliRunner() result = runner.invoke(main, ["plugin", "enable", "nonexistent"]) assert result.exit_code == 0 def test_plugin_enable_and_disable(tmp_pyra_home): from pyra.config.manager import load_config, save_config save_config(_make_config()) plugin_dir = tmp_pyra_home / "plugins" / "myplugin" plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "manifest.json").write_text('{"name": "myplugin", "version": "1.0"}') runner = CliRunner() runner.invoke(main, ["plugin", "enable", "myplugin"]) cfg = load_config() assert "myplugin" in cfg.plugins.enabled runner.invoke(main, ["plugin", "disable", "myplugin"]) cfg = load_config() assert "myplugin" not in cfg.plugins.enabled def test_daemon_commands_exit_cleanly(tmp_pyra_home): runner = CliRunner() for cmd in ["start", "stop", "status", "restart"]: result = runner.invoke(main, ["daemon", cmd]) assert result.exit_code == 0, f"daemon {cmd} exited with {result.exit_code}" def test_main_calls_setup_wizard_when_no_config(tmp_pyra_home, monkeypatch): setup_calls = [] monkeypatch.setattr("pyra.setup.wizard.run_setup", lambda: setup_calls.append(1)) monkeypatch.setattr("pyra.chat.session.start_chat", lambda: None) runner = CliRunner() runner.invoke(main, []) assert len(setup_calls) == 1, "run_setup should be called once when no config exists" def test_main_skips_setup_when_config_exists(tmp_pyra_home, monkeypatch): from pyra.config.manager import save_config save_config(_make_config()) setup_calls = [] monkeypatch.setattr("pyra.setup.wizard.run_setup", lambda: setup_calls.append(1)) monkeypatch.setattr("pyra.chat.session.start_chat", lambda: None) runner = CliRunner() runner.invoke(main, []) assert len(setup_calls) == 0, "run_setup should NOT be called when config already exists" def test_config_slash_command_registered(): from pyra.chat.session import _STATIC_COMMANDS assert "/config" in _STATIC_COMMANDS # ── plugin install ──────────────────────────────────────────────────────────── def test_plugin_install_decline_setup(tmp_pyra_home, monkeypatch): """Declining 'Configure now?' shows manual instructions and exits cleanly.""" from unittest.mock import MagicMock import questionary monkeypatch.setattr( "pyra.plugins.install.install_bundled_plugin", lambda *a, **kw: None ) monkeypatch.setattr( questionary, "confirm", lambda *a, **kw: MagicMock(ask=lambda: False), ) runner = CliRunner() result = runner.invoke(main, ["plugin", "install", "telegram_bot"]) assert result.exit_code == 0 assert "Installed" in result.output assert "Configure later" in result.output def test_plugin_install_error_does_not_prompt(tmp_pyra_home, monkeypatch): """If install fails, the configure prompt is never shown.""" from unittest.mock import MagicMock import questionary monkeypatch.setattr( "pyra.plugins.install.install_bundled_plugin", lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError("not found")), ) confirm_calls = [] monkeypatch.setattr( questionary, "confirm", lambda *a, **kw: confirm_calls.append(1) or MagicMock(ask=lambda: False), ) runner = CliRunner() result = runner.invoke(main, ["plugin", "install", "telegram_bot"]) assert result.exit_code == 0 assert "Error" in result.output assert len(confirm_calls) == 0 # prompt never reached