"""Tests for AgentKit CLI""" import json import os import tempfile from unittest.mock import patch, MagicMock, AsyncMock import pytest from typer.testing import CliRunner runner = CliRunner() class TestVersionCommand: def test_version_outputs_version_string(self): """agentkit version outputs version number""" from agentkit.cli.main import app result = runner.invoke(app, ["version"]) assert result.exit_code == 0 assert "0.1.0" in result.stdout or "fischer-agentkit" in result.stdout def test_version_help(self): """agentkit version --help works""" from agentkit.cli.main import app result = runner.invoke(app, ["version", "--help"]) assert result.exit_code == 0 class TestDoctorCommand: def test_doctor_server_not_running(self): """agentkit doctor returns error when server not running""" from agentkit.cli.main import app result = runner.invoke(app, ["doctor"]) # Should show connection error or "not running" assert result.exit_code != 0 or "not running" in result.stdout.lower() or "connection" in result.stdout.lower() or "error" in result.stdout.lower() def test_doctor_with_custom_port(self): """agentkit doctor --port 9000 uses custom port""" from agentkit.cli.main import app with patch("httpx.Client") as mock_client: result = runner.invoke(app, ["doctor", "--port", "9000"]) # Should attempt to connect to port 9000 def test_doctor_server_running(self): """agentkit doctor returns ok when server is running""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"status": "ok"} mock_response.__enter__ = MagicMock(return_value=mock_response) mock_response.__exit__ = MagicMock(return_value=False) mock_get.return_value = mock_response result = runner.invoke(app, ["doctor"]) # Should show healthy status class TestServeCommand: def test_serve_help(self): """agentkit serve --help shows options""" from agentkit.cli.main import app result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 assert "--host" in result.stdout assert "--port" in result.stdout def test_serve_starts_uvicorn(self): """agentkit serve calls uvicorn.run with correct params""" from agentkit.cli.main import app with patch("uvicorn.run") as mock_run, \ patch("agentkit.server.config.find_config_path", return_value=None), \ patch("rich.prompt.Confirm.ask", return_value=False): result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"]) mock_run.assert_called_once() call_kwargs = mock_run.call_args assert "0.0.0.0" in str(call_kwargs) or 8001 in str(call_kwargs) class TestMainModule: def test_help_shows_all_commands(self): """agentkit --help shows all subcommands""" from agentkit.cli.main import app result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "serve" in result.stdout assert "version" in result.stdout assert "doctor" in result.stdout def test_main_module_entry(self): """python -m agentkit works""" # Just verify the module can be imported import agentkit.__main__ class TestTaskCommands: def test_task_help(self): """agentkit task --help shows subcommands""" from agentkit.cli.main import app result = runner.invoke(app, ["task", "--help"]) assert result.exit_code == 0 assert "submit" in result.stdout assert "status" in result.stdout assert "list" in result.stdout assert "cancel" in result.stdout def test_task_submit_remote_mode(self): """agentkit task submit --server-url calls API""" from agentkit.cli.main import app with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {"result": "ok"}}) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "submit", "--server-url", "http://localhost:8001", "--skill", "content_generator", "--input", '{"topic": "AI"}', ]) assert result.exit_code == 0 def test_task_submit_async_mode(self): """agentkit task submit --mode async returns task_id""" from agentkit.cli.main import app with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.submit_task_async = AsyncMock(return_value={"task_id": "abc-123", "status": "pending"}) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "submit", "--server-url", "http://localhost:8001", "--skill", "content_generator", "--mode", "async", "--input", '{"topic": "AI"}', ]) assert result.exit_code == 0 assert "abc-123" in result.stdout or "pending" in result.stdout def test_task_status(self): """agentkit task status shows status""" from agentkit.cli.main import app with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.get_task_status = AsyncMock(return_value={ "task_id": "abc-123", "status": "completed", "output_data": {"result": "ok"}, }) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "status", "abc-123", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 assert "completed" in result.stdout def test_task_list(self): """agentkit task list shows tasks""" from agentkit.cli.main import app with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.list_tasks = AsyncMock(return_value=[ {"task_id": "abc-123", "status": "completed", "agent_name": "test"}, ]) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "list", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 def test_task_cancel(self): """agentkit task cancel cancels task""" from agentkit.cli.main import app with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.cancel_task = AsyncMock(return_value={"task_id": "abc-123", "status": "cancelled"}) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "cancel", "abc-123", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 def test_task_submit_input_file(self): """agentkit task submit --input-file reads from file""" from agentkit.cli.main import app with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump({"topic": "AI"}, f) f.flush() with patch("agentkit.server.client.AgentKitClient") as mock_client_cls: mock_client = MagicMock() mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {}}) mock_client_cls.return_value = mock_client result = runner.invoke(app, [ "task", "submit", "--server-url", "http://localhost:8001", "--skill", "content_generator", "--input-file", f.name, ]) assert result.exit_code == 0 os.unlink(f.name) def test_task_submit_no_server_url_shows_error(self): """agentkit task submit without --server-url shows error""" from agentkit.cli.main import app result = runner.invoke(app, [ "task", "submit", "--skill", "content_generator", "--input", '{"topic": "AI"}', ]) # Should show error about missing server URL or local mode not available assert result.exit_code != 0 or "server" in result.stdout.lower() or "error" in result.stdout.lower() class TestSkillCommands: def test_skill_help(self): """agentkit skill --help shows subcommands""" from agentkit.cli.main import app result = runner.invoke(app, ["skill", "--help"]) assert result.exit_code == 0 assert "list" in result.stdout assert "load" in result.stdout assert "info" in result.stdout def test_skill_list_remote(self): """agentkit skill list --server-url calls API""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [ {"name": "content_generator", "agent_type": "llm", "description": "Generate content"}, ] mock_response.raise_for_status = MagicMock() mock_get.return_value = mock_response result = runner.invoke(app, [ "skill", "list", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 assert "content_generator" in result.stdout def test_skill_list_empty(self): """agentkit skill list with no skills shows empty message""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [] mock_response.raise_for_status = MagicMock() mock_get.return_value = mock_response result = runner.invoke(app, [ "skill", "list", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 assert "no skill" in result.stdout.lower() or "0" in result.stdout or "empty" in result.stdout.lower() def test_skill_info_remote(self): """agentkit skill info shows skill details""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "name": "content_generator", "agent_type": "llm", "description": "Generate content", "version": "1.0.0", } mock_response.raise_for_status = MagicMock() mock_get.return_value = mock_response result = runner.invoke(app, [ "skill", "info", "content_generator", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 assert "content_generator" in result.stdout def test_skill_load_local(self): """agentkit skill load loads a YAML skill config""" from agentkit.cli.main import app with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: import yaml yaml.dump({ "name": "test_skill", "description": "A test skill", "agent_type": "llm", "task_mode": "llm_generate", "prompt": {"system": "You are a test assistant"}, }, f) f.flush() result = runner.invoke(app, [ "skill", "load", f.name, ]) assert result.exit_code == 0 assert "test_skill" in result.stdout or "loaded" in result.stdout.lower() os.unlink(f.name) def test_skill_load_invalid_file(self): """agentkit skill load with invalid file shows error""" from agentkit.cli.main import app result = runner.invoke(app, [ "skill", "load", "/nonexistent/file.yaml", ]) assert result.exit_code != 0 or "error" in result.stdout.lower() or "not found" in result.stdout.lower() class TestInitCommand: def test_init_non_interactive(self): """agentkit init --non-interactive generates config files""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir]) assert result.exit_code == 0 # Check generated files assert os.path.exists(os.path.join(tmpdir, "agentkit.yaml")) assert os.path.exists(os.path.join(tmpdir, ".env.example")) assert os.path.exists(os.path.join(tmpdir, "docker-compose.yaml")) assert os.path.exists(os.path.join(tmpdir, "skills")) def test_init_agentkit_yaml_content(self): """agentkit init generates valid agentkit.yaml""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir]) import yaml with open(os.path.join(tmpdir, "agentkit.yaml")) as f: config = yaml.safe_load(f) assert "server" in config assert "llm" in config assert config["server"]["port"] == 8001 def test_init_env_example_content(self): """agentkit init generates .env.example with API key placeholders""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir]) with open(os.path.join(tmpdir, ".env.example")) as f: content = f.read() assert "OPENAI_API_KEY" in content or "API_KEY" in content def test_init_docker_compose_content(self): """agentkit init generates docker-compose.yaml with 3 services""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir]) import yaml with open(os.path.join(tmpdir, "docker-compose.yaml")) as f: compose = yaml.safe_load(f) services = compose.get("services", {}) assert "agentkit" in services assert "redis" in services assert "postgres" in services def test_init_existing_files_no_overwrite(self): """agentkit init does not overwrite existing files without --force""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: # Create existing file with open(os.path.join(tmpdir, "agentkit.yaml"), "w") as f: f.write("existing") result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir]) # Should either skip or prompt with open(os.path.join(tmpdir, "agentkit.yaml")) as f: content = f.read() # File should still be "existing" (not overwritten) or overwritten with --force assert content == "existing" or "agentkit" in content.lower() class TestUsageCommand: def test_usage_remote(self): """agentkit usage --server-url calls API""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "total_requests": 10, "total_tokens": 5000, "total_cost": 0.15, } mock_get.return_value = mock_response result = runner.invoke(app, [ "usage", "--server-url", "http://localhost:8001", ]) assert result.exit_code == 0 def test_usage_format_json(self): """agentkit usage --format json outputs JSON""" from agentkit.cli.main import app with patch("httpx.Client.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "total_requests": 10, "total_tokens": 5000, "total_cost": 0.15, } mock_get.return_value = mock_response result = runner.invoke(app, [ "usage", "--server-url", "http://localhost:8001", "--format", "json", ]) assert result.exit_code == 0 def test_usage_no_server(self): """agentkit usage without --server-url shows local usage or error""" from agentkit.cli.main import app result = runner.invoke(app, ["usage"]) # Should either show local usage or error about missing server # Either is acceptable class TestPairCommand: def test_pair_generates_api_key(self): """agentkit pair --name geo generates an API key""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: result = runner.invoke(app, [ "pair", "--name", "geo-backend", "--config-dir", tmpdir, ]) assert result.exit_code == 0 assert "ak_live_" in result.stdout or "api_key" in result.stdout.lower() def test_pair_saves_client_config(self): """agentkit pair saves client registration to clients.yaml""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: result = runner.invoke(app, [ "pair", "--name", "geo-backend", "--config-dir", tmpdir, ]) assert result.exit_code == 0 # Check clients.yaml was created import yaml clients_path = os.path.join(tmpdir, "clients.yaml") assert os.path.exists(clients_path) with open(clients_path) as f: clients = yaml.safe_load(f) assert "geo-backend" in clients assert "api_key" in clients["geo-backend"] assert clients["geo-backend"]["api_key"].startswith("ak_live_") def test_pair_shows_connection_instructions(self): """agentkit pair shows how to connect""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: result = runner.invoke(app, [ "pair", "--name", "geo-backend", "--config-dir", tmpdir, ]) assert result.exit_code == 0 assert "AGENTKIT_API_KEY" in result.stdout or "AGENTKIT_SERVER_URL" in result.stdout def test_pair_rejects_duplicate_name(self): """agentkit pair rejects duplicate client name""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: # First pair runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir]) # Second pair with same name result = runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir]) assert result.exit_code != 0 or "already" in result.stdout.lower() or "exists" in result.stdout.lower() def test_pair_with_custom_skills(self): """agentkit pair --skills-dir registers custom skills for client""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: # Create a skills directory skills_dir = os.path.join(tmpdir, "custom_skills") os.makedirs(skills_dir) import yaml with open(os.path.join(skills_dir, "test_skill.yaml"), "w") as f: yaml.dump({"name": "test_skill", "description": "Test", "agent_type": "assistant", "mode": "llm_generate", "prompt": "You are a test assistant"}, f) result = runner.invoke(app, [ "pair", "--name", "geo-backend", "--skills-dir", skills_dir, "--config-dir", tmpdir, ]) assert result.exit_code == 0 # Check client config includes skills_dir clients_path = os.path.join(tmpdir, "clients.yaml") with open(clients_path) as f: clients = yaml.safe_load(f) assert "skills_dir" in clients["geo-backend"] def test_pair_list(self): """agentkit pair --list shows all paired clients""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: # Pair two clients runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir]) runner.invoke(app, ["pair", "--name", "another-app", "--config-dir", tmpdir]) # List result = runner.invoke(app, ["pair", "--list", "--config-dir", tmpdir]) assert result.exit_code == 0 assert "geo-backend" in result.stdout assert "another-app" in result.stdout def test_pair_revoke(self): """agentkit pair --revoke removes a client""" from agentkit.cli.main import app with tempfile.TemporaryDirectory() as tmpdir: runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir]) result = runner.invoke(app, ["pair", "--revoke", "geo-backend", "--config-dir", tmpdir]) assert result.exit_code == 0 # Check client is removed import yaml clients_path = os.path.join(tmpdir, "clients.yaml") with open(clients_path) as f: clients = yaml.safe_load(f) assert "geo-backend" not in clients