v1.0.0 — Agent Eyes: search + read the entire internet
Major restructure from x-reader fork to independent project: Architecture: - readers/ — content extraction from 10+ platforms (based on x-reader, MIT) - search/ — semantic search via Exa, GitHub API, birdx (NEW) - config.py — configuration management (~/.agent-eyes/config.yaml) (NEW) - doctor.py — environment health checker (NEW) - core.py — AgentEyes unified entry point (NEW) - cli.py — full CLI: read, search, setup, doctor (NEW) - integrations/mcp_server.py — 8 MCP tools (NEW) - guides/ — 6 Agent-readable setup guides (NEW) - integrations/skill/ — OpenClaw Skill package (NEW) Platforms (zero config): - Web pages, GitHub, Bilibili, YouTube, RSS, single tweets Platforms (one free API key): - Web search, Reddit search, Twitter search (via Exa) Platforms (optional setup): - Reddit full reader, Twitter advanced, WeChat, XiaoHongShu Tests: 34/34 passing Credits: Built on x-reader by @runes_leo (MIT License)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes CLI."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from agent_eyes.cli import main
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_version(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-eyes", "version"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Eyes v" in captured.out
|
||||
|
||||
def test_no_command_shows_help(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-eyes"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_doctor_runs(self, capsys):
|
||||
with patch("sys.argv", ["agent-eyes", "doctor"]):
|
||||
main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Eyes" in captured.out
|
||||
assert "✅" in captured.out
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes config module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent_eyes.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
"""Create a Config with a temporary directory."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
return Config(config_path=config_file)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_init_creates_dir(self, tmp_path):
|
||||
config_file = tmp_path / "subdir" / "config.yaml"
|
||||
config = Config(config_path=config_file)
|
||||
assert config_file.parent.exists()
|
||||
|
||||
def test_set_and_get(self, tmp_config):
|
||||
tmp_config.set("test_key", "test_value")
|
||||
assert tmp_config.get("test_key") == "test_value"
|
||||
|
||||
def test_get_default(self, tmp_config):
|
||||
assert tmp_config.get("nonexistent") is None
|
||||
assert tmp_config.get("nonexistent", "default") == "default"
|
||||
|
||||
def test_get_from_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("TEST_ENV_KEY", "env_value")
|
||||
assert tmp_config.get("test_env_key") == "env_value"
|
||||
|
||||
def test_config_file_priority_over_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("MY_KEY", "from_env")
|
||||
tmp_config.set("my_key", "from_config")
|
||||
assert tmp_config.get("my_key") == "from_config"
|
||||
|
||||
def test_save_and_load(self, tmp_config):
|
||||
tmp_config.set("key1", "value1")
|
||||
tmp_config.set("key2", 42)
|
||||
|
||||
# Create new config from same file
|
||||
config2 = Config(config_path=tmp_config.config_path)
|
||||
assert config2.get("key1") == "value1"
|
||||
assert config2.get("key2") == 42
|
||||
|
||||
def test_delete(self, tmp_config):
|
||||
tmp_config.set("to_delete", "value")
|
||||
assert tmp_config.get("to_delete") == "value"
|
||||
tmp_config.delete("to_delete")
|
||||
assert tmp_config.get("to_delete") is None
|
||||
|
||||
def test_is_configured(self, tmp_config):
|
||||
assert not tmp_config.is_configured("exa_search")
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
assert tmp_config.is_configured("exa_search")
|
||||
|
||||
def test_is_configured_reddit(self, tmp_config):
|
||||
assert not tmp_config.is_configured("reddit_proxy")
|
||||
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
|
||||
assert tmp_config.is_configured("reddit_proxy")
|
||||
|
||||
def test_get_configured_features(self, tmp_config):
|
||||
features = tmp_config.get_configured_features()
|
||||
assert isinstance(features, dict)
|
||||
assert "exa_search" in features
|
||||
assert all(v is False for v in features.values())
|
||||
|
||||
def test_to_dict_masks_sensitive(self, tmp_config):
|
||||
tmp_config.set("exa_api_key", "super-secret-key-12345")
|
||||
tmp_config.set("normal_setting", "visible")
|
||||
masked = tmp_config.to_dict()
|
||||
assert masked["exa_api_key"] == "super-se..."
|
||||
assert masked["normal_setting"] == "visible"
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for AgentEyes core class."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from pathlib import Path
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.core import AgentEyes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eyes(tmp_path):
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
return AgentEyes(config=config)
|
||||
|
||||
|
||||
class TestAgentEyes:
|
||||
def test_init(self, eyes):
|
||||
assert eyes.config is not None
|
||||
assert eyes.reader is not None
|
||||
|
||||
def test_detect_platform(self, eyes):
|
||||
assert eyes.detect_platform("https://github.com/openai/gpt-4") == "github"
|
||||
assert eyes.detect_platform("https://reddit.com/r/test") == "reddit"
|
||||
assert eyes.detect_platform("https://x.com/elonmusk/status/123") == "twitter"
|
||||
assert eyes.detect_platform("https://youtube.com/watch?v=abc") == "youtube"
|
||||
assert eyes.detect_platform("https://bilibili.com/video/BV1xx") == "bilibili"
|
||||
assert eyes.detect_platform("https://mp.weixin.qq.com/s/abc") == "wechat"
|
||||
assert eyes.detect_platform("https://example.com") == "generic"
|
||||
|
||||
def test_doctor(self, eyes):
|
||||
results = eyes.doctor()
|
||||
assert isinstance(results, dict)
|
||||
assert "web" in results
|
||||
assert "github_read" in results
|
||||
|
||||
def test_doctor_report(self, eyes):
|
||||
report = eyes.doctor_report()
|
||||
assert isinstance(report, str)
|
||||
assert "Agent Eyes" in report
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes doctor module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_eyes.config import Config
|
||||
from agent_eyes.doctor import check_all, format_report, STATUS_OK, STATUS_OFF
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
return Config(config_path=tmp_path / "config.yaml")
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
def test_zero_config_platforms_always_ok(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["web"]["status"] == STATUS_OK
|
||||
assert results["github_read"]["status"] == STATUS_OK
|
||||
assert results["bilibili"]["status"] == STATUS_OK
|
||||
assert results["rss"]["status"] == STATUS_OK
|
||||
assert results["tweet_read"]["status"] == STATUS_OK
|
||||
|
||||
def test_search_off_without_exa_key(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_web"]["status"] == STATUS_OFF
|
||||
assert results["search_reddit"]["status"] == STATUS_OFF
|
||||
assert results["search_twitter"]["status"] == STATUS_OFF
|
||||
|
||||
def test_search_on_with_exa_key(self, tmp_config):
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_web"]["status"] == STATUS_OK
|
||||
assert results["search_reddit"]["status"] == STATUS_OK
|
||||
assert results["search_twitter"]["status"] == STATUS_OK
|
||||
|
||||
def test_github_search_always_on(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["search_github"]["status"] == STATUS_OK
|
||||
|
||||
def test_reddit_full_off_without_proxy(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
assert results["reddit_full"]["status"] == STATUS_OFF
|
||||
|
||||
def test_reddit_full_on_with_proxy(self, tmp_config):
|
||||
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
|
||||
results = check_all(tmp_config)
|
||||
assert results["reddit_full"]["status"] == STATUS_OK
|
||||
|
||||
@patch("agent_eyes.doctor._check_command")
|
||||
def test_birdx_detection(self, mock_cmd, tmp_config):
|
||||
mock_cmd.return_value = True
|
||||
results = check_all(tmp_config)
|
||||
assert results["twitter_advanced"]["status"] == STATUS_OK
|
||||
|
||||
def test_format_report_is_string(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
report = format_report(results)
|
||||
assert isinstance(report, str)
|
||||
assert "Agent Eyes" in report
|
||||
assert "✅" in report
|
||||
|
||||
def test_format_report_shows_count(self, tmp_config):
|
||||
results = check_all(tmp_config)
|
||||
report = format_report(results)
|
||||
assert "platforms active" in report
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Eyes search modules."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_eyes.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
c = Config(config_path=tmp_path / "config.yaml")
|
||||
c.set("exa_api_key", "test-key")
|
||||
return c
|
||||
|
||||
|
||||
class TestExaSearch:
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_web(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"title": "Test Result",
|
||||
"url": "https://example.com",
|
||||
"text": "This is a test snippet",
|
||||
"publishedDate": "2025-01-01",
|
||||
"score": 0.95,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.exa import search_web
|
||||
results = await search_web("test query", config=tmp_config)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "Test Result"
|
||||
assert results[0]["url"] == "https://example.com"
|
||||
assert results[0]["snippet"] == "This is a test snippet"
|
||||
|
||||
def test_search_web_requires_key(self):
|
||||
from agent_eyes.search.exa import _get_api_key
|
||||
with pytest.raises(ValueError, match="Exa API key"):
|
||||
_get_api_key(Config(config_path="/tmp/nonexistent/config.yaml"))
|
||||
|
||||
|
||||
class TestRedditSearch:
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_reddit(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
results = await search_reddit("test", config=tmp_config)
|
||||
|
||||
# Verify it searched site:reddit.com
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:reddit.com" in query
|
||||
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_reddit_with_sub(self, mock_post, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.reddit import search_reddit
|
||||
await search_reddit("test", subreddit="LocalLLaMA", config=tmp_config)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:reddit.com/r/LocalLLaMA" in query
|
||||
|
||||
|
||||
class TestGitHubSearch:
|
||||
@patch("agent_eyes.search.github.requests.get")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_github(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"full_name": "owner/repo",
|
||||
"html_url": "https://github.com/owner/repo",
|
||||
"description": "A test repo",
|
||||
"stargazers_count": 100,
|
||||
"forks_count": 20,
|
||||
"language": "Python",
|
||||
"updated_at": "2025-01-01",
|
||||
"topics": ["ai"],
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.github import search_github
|
||||
results = await search_github("test")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "owner/repo"
|
||||
assert results[0]["stars"] == 100
|
||||
|
||||
@patch("agent_eyes.search.github.requests.get")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_github_with_language(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"items": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.github import search_github
|
||||
await search_github("test", language="python")
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert "language:python" in call_args[1]["params"]["q"]
|
||||
|
||||
|
||||
class TestTwitterSearch:
|
||||
@patch("agent_eyes.search.twitter.shutil.which", return_value=None)
|
||||
@patch("agent_eyes.search.exa.requests.post")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_twitter_exa_fallback(self, mock_post, mock_which, tmp_config):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
from agent_eyes.search.twitter import search_twitter
|
||||
results = await search_twitter("test", config=tmp_config)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
query = call_args[1]["json"]["query"]
|
||||
assert "site:x.com" in query
|
||||
Reference in New Issue
Block a user