Skill: github2cao/werwe (version 1.0.0)

--- name: x-search description: Search X (Twitter) posts using the xAI API. Use when the user wants to find tweets, search X/Twitter, look up what people are saying on X, or find social media posts about a topic. homepage: https://docs.x.ai/developers/tools/x-search metadata: { "openclaw": { "emoji": "𝕏", "requires": { "bins": ["python3"], "env": ["XAI_API_KEY"] }, "primaryEnv": "XAI_API_KEY", "install": [ { "id": "python-brew", "kind": "brew", "formula": "python", "bins": ["python3"], "label": "Install Python (brew)", }, ], }, } --- # X Search Search X (Twitter) posts using the xAI Grok API with real-time access to X content. ## Setup 1. Get your API key: https://console.x.ai 2. Set environment variable: ```bash export XAI_API_KEY="xai-your-key-here" ``` 3. Or set `skills."x-search".apiKey` / `skills."x-search".env.XAI_API_KEY` in `~/.openclaw/openclaw.json` ## Usage ```bash python3 {baseDir}/scripts/search.py "what is trending in AI right now" ``` Useful flags: ```bash # Filter by handles (max 10) python3 {baseDir}/scripts/search.py --handles jaaneek,OpenClaw "latest posts" # Exclude handles (max 10) python3 {baseDir}/scripts/search.py --exclude spambot,crypto_shill "trending AI" # Date range (YYYY-MM-DD) python3 {baseDir}/scripts/search.py --from 2026-03-01 --to 2026-03-20 "xAI grok updates" # Enable image/video understanding in posts python3 {baseDir}/scripts/search.py --images --video "product demos" ``` ## Notes - Uses the xAI Responses API with `x_search` tool (Grok performs the search and summarizes results) - Cannot use `--handles` and `--exclude` at the same time - Results include citations with links to original X posts - Present results in a readable format with post content, authors, and citations

path: _meta.json
content: ownerId: kn7ashtwsjfzjzfz11zjsar4dn837v27
slug: x-search
version: 1.0.0
publishedAt: 1773970789565

path: scripts/test_search.py
content: import importlib.util
from pathlib import Path

import pytest

MODULE_PATH = Path(__file__).with_name("search.py")
SPEC = importlib.util.spec_from_file_location("search", MODULE_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)


class TestParseArgs:
    def test_basic_query(self):
        opts = MODULE.parse_args(["prog", "what", "is", "openclaw"])
        assert opts["query"] == ["what", "is", "openclaw"]

    def test_handles_single(self):
        opts = MODULE.parse_args(["prog", "--handles", "jaaneek", "grok updates"])
        assert opts["handles"] == ["jaaneek"]
        assert opts["query"] == ["grok updates"]

    def test_handles_multiple(self):
        opts = MODULE.parse_args(["prog", "--handles", "jaaneek,OpenClaw,xaboratory", "AI news"])
        assert opts["handles"] == ["jaaneek", "OpenClaw", "xaboratory"]

    def test_exclude(self):
        opts = MODULE.parse_args(["prog", "--exclude", "spambot,crypto_shill", "trending AI"])
        assert opts["exclude"] == ["spambot", "crypto_shill"]

    def test_date_range(self):
        opts = MODULE.parse_args(["prog", "--from", "2026-03-01", "--to", "2026-03-20", "xAI releases"])
        assert opts["from_date"] == "2026-03-01"
        assert opts["to_date"] == "2026-03-20"

    def test_images_and_video_flags(self):
        opts = MODULE.parse_args(["prog", "--images", "--video", "product demos"])
        assert opts["images"] is True
        assert opts["video"] is True

    def test_at_prefix_stripped(self):
        opts = MODULE.parse_args(["prog", "--handles", "@jaaneek,@OpenClaw", "latest posts"])
        assert opts["handles"] == ["jaaneek", "OpenClaw"]

    def test_equals_syntax(self):
        opts = MODULE.parse_args(["prog", "--handles=jaaneek", "--from=2026-03-01", "grok"])
        assert opts["handles"] == ["jaaneek"]
        assert opts["from_date"] == "2026-03-01"

    def test_double_dash_stops_flag_parsing(self):
        opts = MODULE.parse_args(["prog", "--handles", "jaaneek", "--", "--images", "not a flag"])
        assert opts["handles"] == ["jaaneek"]
        assert opts["images"] is False
        assert opts["query"] == ["--images", "not a flag"]

    def test_empty_handles_after_comma_filter(self):
        opts = MODULE.parse_args(["prog", "--handles", ",,", "query"])
        assert opts["handles"] == []

    def test_query_before_flags(self):
        opts = MODULE.parse_args(["prog", "grok news", "--handles", "jaaneek"])
        assert opts["handles"] == ["jaaneek"]
        assert opts["query"] == ["grok news"]

    def test_trailing_commas_in_handles(self):
        opts = MODULE.parse_args(["prog", "--handles", ",jaaneek,,OpenClaw,", "query"])
        assert opts["handles"] == ["jaaneek", "OpenClaw"]

    def test_help_flag(self):
        with pytest.raises(SystemExit) as exc:
            MODULE.parse_args(["prog", "--help"])
        assert exc.value.code == 0

    def test_no_args(self):
        with pytest.raises(SystemExit) as exc:
            MODULE.parse_args(["prog"])
        assert exc.value.code == 1

    def test_unknown_flag_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.parse_args(["prog", "--verbose", "query"])

    def test_flag_missing_value(self):
        with pytest.raises(SystemExit):
            MODULE.parse_args(["prog", "query", "--handles"])


class TestValidate:
    def _opts(self, **kw):
        base = {"handles": None, "exclude": None, "from_date": None, "to_date": None}
        base.update(kw)
        return base

    def test_valid_handles(self):
        MODULE.validate(self._opts(handles=["jaaneek", "OpenClaw"]))

    def test_valid_dates(self):
        MODULE.validate(self._opts(from_date="2026-01-01", to_date="2026-03-20"))

    def test_handles_and_exclude_conflict(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=["jaaneek"], exclude=["spambot"]))

    def test_empty_handles_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=[]))

    def test_empty_exclude_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(exclude=[]))

    def test_handle_with_space_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=["jan eek"]))

    def test_handle_too_long_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=["a" * 16]))

    def test_handle_15_chars_accepted(self):
        MODULE.validate(self._opts(handles=["a" * 15]))

    def test_over_ten_handles_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=[f"user{i}" for i in range(11)]))

    def test_exactly_ten_handles_accepted(self):
        MODULE.validate(self._opts(handles=[f"user{i}" for i in range(10)]))

    def test_bad_date_format_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(from_date="March 1st"))

    def test_impossible_date_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(from_date="2026-02-30"))

    def test_inverted_date_range_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(from_date="2026-03-20", to_date="2026-01-01"))

    def test_same_date_accepted(self):
        MODULE.validate(self._opts(from_date="2026-03-20", to_date="2026-03-20"))

    def test_unicode_handle_rejected(self):
        with pytest.raises(SystemExit):
            MODULE.validate(self._opts(handles=["日本語"]))


class TestBuildToolConfig:
    def test_no_options(self):
        cfg = MODULE.build_tool_config({
            "handles": None, "exclude": None,
            "from_date": None, "to_date": None,
            "images": False, "video": False,
        })
        assert cfg == {"type": "x_search"}

    def test_handles_only(self):
        cfg = MODULE.build_tool_config({
            "handles": ["jaaneek"], "exclude": None,
            "from_date": None, "to_date": None,
            "images": False, "video": False,
        })
        assert cfg == {"type": "x_search", "allowed_x_handles": ["jaaneek"]}

    def test_exclude_only(self):
        cfg = MODULE.build_tool_config({
            "handles": None, "exclude": ["spambot"],
            "from_date": None, "to_date": None,
            "images": False, "video": False,
        })
        assert cfg == {"type": "x_search", "excluded_x_handles": ["spambot"]}

    def test_all_options(self):
        cfg = MODULE.build_tool_config({
            "handles": ["jaaneek", "OpenClaw"], "exclude": None,
            "from_date": "2026-03-01", "to_date": "2026-03-20",
            "images": True, "video": True,
        })
        assert cfg["type"] == "x_search"
        assert cfg["allowed_x_handles"] == ["jaaneek", "OpenClaw"]
        assert cfg["from_date"] == "2026-03-01"
        assert cfg["to_date"] == "2026-03-20"
        assert cfg["enable_image_understanding"] is True
        assert cfg["enable_video_understanding"] is True


class TestFormatResponse:
    def test_completed_with_citations(self):
        data = {
            "status": "completed",
            "output": [{"type": "message", "content": [{"type": "output_text", "text": "jaaneek posted about AI agents", "annotations": [
                {"type": "url_citation", "url": "https://x.com/jaaneek/status/123", "title": "jaaneek's post"},
            ]}]}],
            "usage": {"input_tokens": 500, "output_tokens": 100, "server_side_tool_usage_details": {"x_search_calls": 3}},
        }
        r = MODULE.format_response(data, "jaaneek AI")
        assert r["status"] == "completed"
        assert r["query"] == "jaaneek AI"
        assert "jaaneek" in r["text"]
        assert r["citations"][0]["url"] == "https://x.com/jaaneek/status/123"
        assert r["searches"] == 3
        assert r["tokens"]["input"] == 500

    def test_empty_response(self):
        r = MODULE.format_response({}, "openclaw skills")
        assert r["status"] == "unknown"
        assert r["text"] == ""
        assert r["citations"] == []
        assert r["searches"] == 0

    def test_malformed_output_types(self):
        r = MODULE.format_response({"status": "completed", "output": "not a list", "usage": 42}, "test")
        assert r["status"] == "completed"
        assert r["text"] == ""
        assert r["tokens"] == {"input": 0, "output": 0}

    def test_malformed_content_type(self):
        r = MODULE.format_response({"status": "completed", "output": [{"type": "message", "content": "bad"}], "usage": {}}, "test")
        assert r["text"] == ""

    def test_malformed_annotations_type(self):
        data = {"status": "completed", "output": [{"type": "message", "content": [{"text": "hi", "annotations": "bad"}]}], "usage": {}}
        r = MODULE.format_response(data, "test")
        assert r["text"] == "hi"
        assert r["citations"] == []

    def test_citations_without_url_filtered(self):
        data = {
            "status": "completed",
            "output": [{"type": "message", "content": [{"type": "output_text", "text": "results", "annotations": [
                {"type": "url_citation", "url": "https://x.com/jaaneek/status/456", "title": "valid"},
                {"type": "url_citation"},
                {"type": "url_citation", "url": "", "title": "empty url"},
            ]}]}],
            "usage": {},
        }
        r = MODULE.format_response(data, "test")
        assert len(r["citations"]) == 1
        assert r["citations"][0]["text"] == "valid"

    def test_failed_status_includes_error(self):
        data = {"status": "failed", "error": {"message": "rate limit exceeded"}, "output": [], "usage": {}}
        r = MODULE.format_response(data, "trending")
        assert r["status"] == "failed"
        assert "rate limit exceeded" in r["text"]

    def test_failed_status_with_string_error(self):
        data = {"status": "failed", "error": "something broke", "output": [], "usage": {}}
        r = MODULE.format_response(data, "test")
        assert "something broke" in r["text"]

    def test_multiple_content_blocks_joined(self):
        data = {
            "status": "completed",
            "output": [{"type": "message", "content": [
                {"type": "output_text", "text": "OpenClaw update:", "annotations": [
                    {"type": "url_citation", "url": "https://x.com/OpenClaw/status/1", "title": "post 1"},
                ]},
                {"type": "output_text", "text": "jaaneek contributed x-search skill", "annotations": [
                    {"type": "url_citation", "url": "https://x.com/jaaneek/status/2", "title": "post 2"},
                ]},
            ]}],
            "usage": {},
        }
        r = MODULE.format_response(data, "openclaw skills")
        assert "OpenClaw update:" in r["text"]
        assert "jaaneek contributed" in r["text"]
        assert "\n\n" in r["text"]
        assert len(r["citations"]) == 2

    def test_no_message_in_output(self):
        data = {
            "status": "completed",
            "output": [{"type": "reasoning", "status": "completed"}],
            "usage": {"input_tokens": 50, "output_tokens": 0},
        }
        r = MODULE.format_response(data, "test")
        assert r["text"] == ""
        assert r["citations"] == []


path: scripts/search.py
content: #!/usr/bin/env python3
"""Search X (Twitter) posts using the xAI Grok API."""

import json
import os
import re
import sys
from datetime import datetime
import signal
from urllib.request import Request, HTTPRedirectHandler, build_opener
from urllib.error import URLError, HTTPError


class _NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise HTTPError(newurl, code, f"Redirect to {newurl} blocked (auth safety)", headers, fp)

API_URL = "https://api.x.ai/v1/responses"
MODEL = "grok-4.20-reasoning"
TIMEOUT_S = 120
MAX_HANDLES = 10
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
HANDLE_RE = re.compile(r"^[a-zA-Z0-9_]{1,15}$")
FLAGS_WITH_VALUES = {"--handles", "--exclude", "--from", "--to"}


def die(msg: str) -> None:
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(1)


def parse_args(argv: list[str]) -> dict:
    args = argv[1:]
    options: dict = {
        "handles": None,
        "exclude": None,
        "from_date": None,
        "to_date": None,
        "images": False,
        "video": False,
        "query": [],
    }

    if not args or args[0] in ("-h", "--help"):
        print(
            'Usage: python3 search.py [--handles h1,h2] [--exclude h1,h2] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--images] [--video] "<query>"',
            file=sys.stderr,
        )
        sys.exit(0 if args else 1)

    # Expand --flag=value into --flag value
    expanded: list[str] = []
    for a in args:
        if "=" in a and a.split("=", 1)[0] in FLAGS_WITH_VALUES:
            expanded.extend(a.split("=", 1))
        else:
            expanded.append(a)

    i = 0
    flags_done = False
    while i < len(expanded):
        arg = expanded[i]

        if arg == "--" and not flags_done:
            flags_done = True
            i += 1
            continue

        if not flags_done and arg in FLAGS_WITH_VALUES and (i + 1 >= len(expanded) or expanded[i + 1].startswith("--")):
            die(f"{arg} requires a value.")

        if not flags_done and arg == "--handles":
            i += 1
            options["handles"] = [h.strip().lstrip("@") for h in expanded[i].split(",") if h.strip().lstrip("@")]
        elif not flags_done and arg == "--exclude":
            i += 1
            options["exclude"] = [h.strip().lstrip("@") for h in expanded[i].split(",") if h.strip().lstrip("@")]
        elif not flags_done and arg == "--from":
            i += 1
            options["from_date"] = expanded[i]
        elif not flags_done and arg == "--to":
            i += 1
            options["to_date"] = expanded[i]
        elif not flags_done and arg == "--images":
            options["images"] = True
        elif not flags_done and arg == "--video":
            options["video"] = True
        elif not flags_done and arg.startswith("--"):
            die(f'Unknown flag "{arg}". Use --help for usage.')
        else:
            options["query"].append(arg)

        i += 1

    return options


def validate(options: dict) -> None:
    handles = options["handles"]
    exclude = options["exclude"]
    from_date = options["from_date"]
    to_date = options["to_date"]

    if handles is not None and exclude is not None:
        die("--handles and --exclude cannot be used together.")

    if handles is not None and len(handles) == 0:
        die("--handles value is empty.")

    if exclude is not None and len(exclude) == 0:
        die("--exclude value is empty.")

    for h in (handles or []) + (exclude or []):
        if not HANDLE_RE.match(h):
            die(f'"{h}" is not a valid X handle (letters, numbers, underscores, max 15 chars).')

    if handles and len(handles) > MAX_HANDLES:
        die(f"--handles accepts at most {MAX_HANDLES} handles, got {len(handles)}.")

    if exclude and len(exclude) > MAX_HANDLES:
        die(f"--exclude accepts at most {MAX_HANDLES} handles, got {len(exclude)}.")

    if from_date and not DATE_RE.match(from_date):
        die(f'--from must be YYYY-MM-DD format, got "{from_date}".')

    if to_date and not DATE_RE.match(to_date):
        die(f'--to must be YYYY-MM-DD format, got "{to_date}".')

    if from_date:
        try:
            datetime.strptime(from_date, "%Y-%m-%d")
        except ValueError:
            die(f'--from date "{from_date}" is not a valid date.')

    if to_date:
        try:
            datetime.strptime(to_date, "%Y-%m-%d")
        except ValueError:
            die(f'--to date "{to_date}" is not a valid date.')

    if from_date and to_date and from_date > to_date:
        die(f"--from date ({from_date}) must be before --to date ({to_date}).")


def build_tool_config(options: dict) -> dict:
    tool: dict = {"type": "x_search"}

    if options["handles"]:
        tool["allowed_x_handles"] = options["handles"]
    if options["exclude"]:
        tool["excluded_x_handles"] = options["exclude"]
    if options["from_date"]:
        tool["from_date"] = options["from_date"]
    if options["to_date"]:
        tool["to_date"] = options["to_date"]
    if options["images"]:
        tool["enable_image_understanding"] = True
    if options["video"]:
        tool["enable_video_understanding"] = True

    return tool


def _safe_get(obj: object, key: str, default: object = None) -> object:
    """Safely get a key from a dict-like object, returning default if obj is not a dict."""
    if isinstance(obj, dict):
        return obj.get(key, default)
    return default


def format_response(data: dict, query: str) -> dict:
    outputs = _safe_get(data, "output", [])
    if not isinstance(outputs, list):
        outputs = []
    usage = _safe_get(data, "usage", {})
    if not isinstance(usage, dict):
        usage = {}
    tool_details = _safe_get(usage, "server_side_tool_usage_details", {})
    if not isinstance(tool_details, dict):
        tool_details = {}

    message = next((o for o in outputs if isinstance(o, dict) and o.get("type") == "message"), None)
    content_blocks = _safe_get(message, "content", [])
    if not isinstance(content_blocks, list):
        content_blocks = []

    text = "\n\n".join(
        c.get("text", "")
        for c in content_blocks
        if isinstance(c, dict) and c.get("text")
    )
    annotations = [
        a
        for c in content_blocks if isinstance(c, dict)
        for a in (c.get("annotations") or []) if isinstance(a, dict)
    ]

    citations = [
        {"text": a.get("title", ""), "url": a["url"]}
        for a in annotations
        if a.get("type") == "url_citation" and a.get("url")
    ]

    status = _safe_get(data, "status", "unknown") or "unknown"
    if status not in ("completed", "unknown"):
        error = _safe_get(data, "error", {})
        error_msg = error.get("message", "") if isinstance(error, dict) else str(error)
        text = f"Search {status}" + (f": {error_msg}" if error_msg else "") + ("\n\n" + text if text else "")

    return {
        "status": status,
        "query": query,
        "text": text,
        "citations": citations,
        "searches": tool_details.get("x_search_calls", 0),
        "tokens": {
            "input": usage.get("input_tokens", 0),
            "output": usage.get("output_tokens", 0),
        },
    }


def search(options: dict) -> None:
    [REDACTED_SECRET]"XAI_API_KEY", "").strip()
    if not api_key:
        die("XAI_API_KEY environment variable is not set.")

    query = " ".join(w for w in options["query"] if w).strip()
    if not query:
        print("Error: No search query provided.", file=sys.stderr)
        print(
            'Usage: python3 search.py [--handles h1,h2] [--exclude h1,h2] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--images] [--video] "<query>"',
            file=sys.stderr,
        )
        sys.exit(1)

    body = json.dumps({
        "model": MODEL,
        "input": [{"role": "user", "content": query}],
        "tools": [build_tool_config(options)],
    }).encode()

    req = Request(
        API_URL,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
            "User-Agent": "OpenClaw/x-search",
        },
        method="POST",
    )

    opener = build_opener(_NoRedirect)
    try:
        with opener.open(req, timeout=TIMEOUT_S) as resp:
            data = json.loads(resp.read())
    except HTTPError as e:
        try:
            error_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
        except Exception:
            error_body = ""
        die(f"API error ({e.code}): {error_body}")
    except URLError as e:
        die(f"Network request failed: {e.reason}")
    except TimeoutError:
        die(f"Request timed out after {TIMEOUT_S}s.")
    except json.JSONDecodeError:
        die("Failed to parse API response as JSON.")
    except OSError as e:
        die(f"Connection error: {e}")

    output = format_response(data, query)
    print(json.dumps(output, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    signal.signal(signal.SIGINT, lambda *_: (print("\nInterrupted.", file=sys.stderr), sys.exit(130)))
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    options = parse_args(sys.argv)
    validate(options)
    search(options)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)