
X Research
- 15 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
x-research is a Claude skill that researches X/Twitter through the xAI Grok API x_search tool, returning digests, trend analysis, and thread breakdowns with citations.
About
This skill researches X/Twitter through the xAI Grok API's x_search tool, which searches X in real time and returns synthesized analysis with citations. A developer uses it to build account digests, analyze a thread or topic, track trending topics, and run custom searches. Results are structured JSON items saved as reusable cache artifacts, oriented toward finding Telegram post ideas.
- Researches X/Twitter in real time via the xAI Grok API x_search tool with citations
- Produces digests, thread analysis, trending topics, and custom searches as structured JSON
- Saves each run as an immutable cache artifact and can reuse it with --prefer-cache
X Research by the numbers
- 15 all-time installs (skills.sh)
- Ranked #1,404 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
x-research capabilities & compatibility
Requires an xAI API key; each call makes a billable Grok API request.
- Capabilities
- research · web search
- Works with
- openai
- Use cases
- research · web search
- Pricing
- Bring your own API key
What x-research says it does
Research X/Twitter via xAI Grok API. Grok's `x_search` tool searches X in real-time and returns synthesized analysis with citations.
Live-first** — every call makes a fresh API request. Real-time data, never stale.
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill x-researchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Research X/Twitter for digests, trending topics, or thread analysis via the Grok x_search tool.
Who is it for?
Real-time X/Twitter digests, trend tracking, and thread analysis via Grok.
When should I use this skill?
You need to research X/Twitter, build an X digest, analyze a tweet or thread, or find trending topics.
What you get
Structured, cited digests, trend reports, and thread analyses of X are produced and cached for reuse.
By the numbers
- 4 workflows: digest, analyze, trending, and search
- period options include 1h, today, yesterday, week, and Nd
Files
x-research
Research X/Twitter via xAI Grok API. Grok's x_search tool searches X in real-time and returns synthesized analysis with citations.
Перед запуском
Перед любым запуском скриптов создай рабочую копию скилла в /home/claude/x-research и работай уже из неё:
sh scripts/prepare_runtime.sh
cd /home/claude/x-researchprepare_runtime.sh копирует скилл и, если есть config/.env, нормализует его через sed -i: некавыченные значения с пробелами оборачиваются в кавычки в рабочей копии. Не выводи содержимое .env в ответ; при ошибке формата подскажи пользователю, какие строки нужно заключить в кавычки.
Config
Get an xAI API key at console.x.ai, then:
cp config/.env.example config/.envEdit config/.env: paste your key into XAI_API_KEY, configure accounts and topics. Quote any value that contains spaces so the file stays shell-compatible. Шаг подготовки также чинит некавыченные значения с пробелами в скопированном .env.
Without `.env`: skill works with explicit --accounts, --query, --topics.
With `.env`: digests and trending ready out of the box.
Category registry (same pattern as telegram-channel-parser):
X_CATEGORIES=ai,crypto # available categories
X_DEFAULT_CATEGORY=ai # default for "digest" without specifying
X_ACCOUNTS_AI_LABEL="AI & ML"
X_ACCOUNTS_AI=elonmusk,sama,AndrewYNg
X_TOPICS_AI=AI,LLM,GPT,Claude,agents,AGIAgent algorithm for digest/trending: 1. Read config/.env 2. Parse X_CATEGORIES to get available categories 3. For each: X_ACCOUNTS_<ID> = handles, X_TOPICS_<ID> = topics, X_ACCOUNTS_<ID>_LABEL = name 4. Match user request to category label or use X_DEFAULT_CATEGORY 5. Pass to script via --accounts / --topics
Priority: --accounts/--topics explicit > category from .env > agent asks.
Details: config/README.md.
Philosophy
1. Live-first — every call makes a fresh API request. Real-time data, never stale. 2. Artifact store — each run saves an immutable snapshot in cache/runs/. Use --prefer-cache to reuse. 3. Structured output — digest/trending/search return JSON with individual items for machine consumption. 4. Context hygiene — stdout limited to 30 lines. Full data in artifact file. 5. Config-driven model — model set in .env, auto-fallback if unavailable.
Workflow
Digest of subscribed accounts
# Default category from .env
bash scripts/digest.sh --period today
# Specific category
bash scripts/digest.sh --category crypto --period week
# Explicit accounts (no .env needed)
bash scripts/digest.sh --accounts "elonmusk,sama" --period todayReturns: structured items (author, date, summary, engagement, URL, topic) + Telegram post ideas.
Analyze a post/thread/topic
# By description
bash scripts/analyze.sh --query "Elon Musk's thread about open source AI"
# By URL
bash scripts/analyze.sh --url "https://x.com/elonmusk/status/123456" --query "context about the post"
# With time period
bash scripts/analyze.sh --query "debate about AI regulation" --period weekReturns: main thesis, key arguments, community reaction, sentiment, interesting findings, Telegram post angles.
Trending topics
# Default topics from .env
bash scripts/trending.sh --period today
# Specific topics
bash scripts/trending.sh --topics "Bitcoin,Ethereum,DeFi" --period today
# Category
bash scripts/trending.sh --category ai --period weekReturns: structured items (topic, summary, sentiment, key voices, engagement) + Telegram post ideas.
Custom search
# Free search
bash scripts/search.sh --query "Claude 4 reactions" --period week
# Search within specific accounts
bash scripts/search.sh --query "AI safety" --accounts "sama,ylecun" --period todayReturns: structured items + narrative summary + Telegram post ideas.
Reuse previous results
# Read last digest without making an API call
bash scripts/digest.sh --category ai --period today --prefer-cache
# Find artifact manually
bash scripts/find_latest.sh --script digest --category ai --period todayScripts
bash scripts/<script>.sh [params]| Script | Description | Key params |
|---|---|---|
digest.sh | Digest of subscribed accounts | --category, --accounts, --period |
analyze.sh | Deep analysis of post/thread/topic | --query, --url, --period |
trending.sh | Trending topics by interests | --category, --topics, --period |
search.sh | Custom search query | --query, --accounts, --period |
find_latest.sh | Find latest cached artifact | --script, --category, --query, --period |
Common parameters
| Param | Required | Default | Description |
|---|---|---|---|
--accounts | no | from .env | X handles, comma-separated (without @) |
--category | no | from .env | Category ID from X_CATEGORIES |
--period | no | today | Time range: 1h, today, yesterday, week, Nd |
--query | varies | — | Search query or post description |
--topics | no | from .env | Topics for trending, comma-separated |
--url | no | — | X post URL for analyze |
--limit | no | 30 | Max output lines |
--prefer-cache | no | — | Use latest cached artifact if available |
--refresh | no | — | Force live request (default behavior) |
Artifact store
Each run saves a JSON artifact in cache/runs/ with full metadata:
{
"meta": {
"script": "digest",
"category": "ai",
"handles": ["elonmusk", "sama"],
"period": "today",
"from_date": "2026-04-01",
"to_date": "2026-04-01",
"model": "grok-4-1-fast-reasoning",
"created_at": "2026-04-01T14:30:22Z",
"run_id": "a1b2c3"
},
"items": [...],
"ideas": [...],
"summary": "...",
"text": "...",
"citations": [...]
}Index: cache/index.jsonl — one JSON line per run for fast lookup.
API limits
- x_search: max 10 handles per
allowed_x_handles(auto-batched if more) - Rate limits: tier-based, see docs.x.ai/developers/rate-limits
- Tool pricing: x_search invocations billed separately from tokens
- Model fallback: if primary model unavailable (HTTP 422/400), auto-retry with
grok-4.20-reasoning
Limitations
- Only public posts (x_search does not access private/protected accounts)
- x_search is a server-side Grok tool — we get synthesized analysis, not raw post data
- Structured JSON output depends on model compliance; fallback to plain text if parsing fails
- No DM access, no analytics data (only public engagement metrics)
Advanced scenarios: references/API_REFERENCE.md
config/.env
cache/runs/
cache/index.jsonl
# X Research — config
# Copy to .env to enable: cp .env.example .env
# Without .env the skill still works — just pass --accounts and --query explicitly.
# Keep the file shell-compatible: quote any value that contains spaces.
# ─── API credentials ─────────────────────────────────────────
# Get your key at https://console.x.ai/
XAI_API_KEY=xai-your-key-here
# ─── Model ───────────────────────────────────────────────────
# Config-driven; fallback to grok-4.20-reasoning if unavailable.
# Full list: https://docs.x.ai/developers/models
XAI_MODEL=grok-4-1-fast-reasoning
# ─── Category registry ───────────────────────────────────────
# List of available research categories.
# Agent reads this to know what to offer the user.
# Format: comma-separated category IDs (lowercase)
X_CATEGORIES=ai
X_DEFAULT_CATEGORY=ai
# ─── Category: AI ────────────────────────────────────────────
X_ACCOUNTS_AI_LABEL="AI & ML"
X_ACCOUNTS_AI=elonmusk,sama,AndrewYNg,kaborja
# Topics for trending research (optional)
X_TOPICS_AI=AI,LLM,GPT,Claude,agents,AGI
# ─── Add your own categories ─────────────────────────────────
# 1. Add category ID to X_CATEGORIES: X_CATEGORIES=ai,crypto,news
# 2. Define accounts and topics:
#
# X_ACCOUNTS_CRYPTO_LABEL="Crypto"
# X_ACCOUNTS_CRYPTO=VitalikButerin,cz_binance
# X_TOPICS_CRYPTO=Bitcoin,Ethereum,DeFi,Web3
#
# Quote topic lists if any item contains spaces:
# X_TOPICS_OPENAI="GPT,ChatGPT,OpenAI,Codex,gpt 5.4"
# X_TOPICS_ANTHROPIC="Claude,Anthropic,ClaudeCode,Claude Code"
#
# X_ACCOUNTS_NEWS_LABEL="Tech News"
# X_ACCOUNTS_NEWS=TechCrunch,veraborisova
# X_TOPICS_NEWS=startups,funding,IPO
X Research — Config Setup
1. Get xAI API Key
1. Go to console.x.ai 2. Sign up / log in 3. Navigate to API Keys 4. Create a new key (starts with xai-)
2. Create .env
cp .env.example .envEdit .env and paste your key into XAI_API_KEY.
Important: keep .env shell-compatible. If a value contains spaces, wrap the whole value in quotes. Some runners use . config/.env, and unquoted values such as Claude Code will be treated as commands.
Перед запуском скриптов из установленного скилла сначала создайте рабочую копию:
sh scripts/prepare_runtime.sh
cd /home/claude/x-researchПодготовка также запускает sed -i для скопированного config/.env и заключает некавыченные значения с пробелами в кавычки. Не выводите содержимое .env при разборе ошибок настройки; указывайте пользователю только имена переменных, которые нужно исправить.
3. Configure accounts & topics
Edit .env to add X accounts you want to follow and topics you care about.
Category system (same as telegram-channel-parser):
# Register categories
X_CATEGORIES=ai,crypto
# Define each category
X_ACCOUNTS_AI_LABEL="AI & ML"
X_ACCOUNTS_AI=elonmusk,sama,AndrewYNg
X_ACCOUNTS_CRYPTO_LABEL="Crypto"
X_ACCOUNTS_CRYPTO=VitalikButerin,cz_binance
# Quote values with spaces
X_TOPICS_OPENAI="GPT,ChatGPT,OpenAI,Codex,gpt 5.4"
X_TOPICS_ANTHROPIC="Claude,Anthropic,ClaudeCode,Claude Code"Note: allowed_x_handles API limit is 10 per request. Categories with >10 accounts are automatically batched.
4. Model selection
Default model: grok-4-1-fast-reasoning (fast & cheap).
If unavailable, auto-fallback to grok-4.20-reasoning.
Override in .env:
XAI_MODEL=grok-4.20-reasoningCheck available models: docs.x.ai/developers/models
xAI Grok API Reference for x-research
Endpoint
POST https://api.x.ai/v1/responses
Authorization: Bearer $XAI_API_KEY
Content-Type: application/jsonx_search tool
Server-side tool — Grok performs the search internally and returns synthesized results.
Request format
{
"model": "grok-4-1-fast-reasoning",
"input": [
{"role": "developer", "content": "System prompt here"},
{"role": "user", "content": "Search query here"}
],
"tools": [
{
"type": "x_search",
"allowed_x_handles": ["elonmusk", "sama"],
"from_date": "2026-04-01",
"to_date": "2026-04-02",
"enable_image_understanding": false,
"enable_video_understanding": false
}
]
}x_search parameters
| Parameter | Type | Description |
|---|---|---|
allowed_x_handles | array | Restrict to these accounts (max 10). Mutually exclusive with excluded_x_handles |
excluded_x_handles | array | Exclude these accounts (max 10) |
from_date | string | Start date, ISO8601 format |
to_date | string | End date, ISO8601 format |
enable_image_understanding | bool | Analyze images in posts |
enable_video_understanding | bool | Analyze videos in posts (x_search only) |
Response format
{
"id": "resp_...",
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Synthesized analysis text...",
"annotations": [
{
"type": "url_citation",
"url": "https://x.com/user/status/123",
"title": "...",
"start_index": 0,
"end_index": 50
}
]
}
]
}
],
"usage": {
"input_tokens": 1234,
"output_tokens": 567
}
}Models
| Model | Input $/1K | Output $/1K | Notes |
|---|---|---|---|
grok-4-1-fast-reasoning | $0.20 | $0.50 | Fast, cheap, good for most tasks |
grok-4.20-reasoning | $2.00 | $6.00 | Most capable, for deep analysis |
grok-4-1-fast-non-reasoning | $0.20 | $0.50 | No chain-of-thought |
grok-4.20-non-reasoning | $2.00 | $6.00 | No chain-of-thought |
Tool invocations: x_search billed separately (check current pricing at console.x.ai).
Full model list: https://docs.x.ai/developers/models
Rate limits
Tier-based (cumulative spending since Jan 1, 2026):
- Tier 0: $0 (default) — limited RPM/TPM
- Tier 1: $50+ — increased limits
- Tier 2: $250+
- Tier 3: $1,000+
- Tier 4: $5,000+
On rate limit (429): retry after Retry-After header value.
Details: https://docs.x.ai/developers/rate-limits
Search capabilities
The x_search tool supports multiple search modes (selected automatically by the model):
- Keyword search — traditional text matching with X advanced operators
- Semantic search — meaning-based search, catches related discussions
- User search — find accounts by name/handle
- Thread fetch — retrieve full thread by post reference
Advanced operators in prompts
You can include X search operators in your prompt text:
from:username— posts by specific usersince:YYYY-MM-DD/until:YYYY-MM-DD— date rangemin_faves:N— minimum likesfilter:images/filter:videos— media typelang:en/lang:ru— language filter
These are passed through the prompt, not as API parameters.
Batch handling
allowed_x_handles is limited to 10 per request. For larger account lists: 1. Split into batches of 10 2. Make separate API calls per batch 3. Merge results: dedup by item.url, sort by item.date
#!/bin/sh
# Analyze: deep analysis of a specific X post, thread, or topic
# Usage: bash scripts/analyze.sh --query "post description or topic" [--url "https://x.com/..."] [--period today]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
if [ -z "$QUERY" ] && [ -z "$URL" ]; then
echo "Error: --query or --url is required." >&2
echo "Usage: bash scripts/analyze.sh --query \"topic or post description\" [--url \"https://x.com/...\"]" >&2
exit 1
fi
# When URL is provided, don't restrict date range — the post could be from any date.
# Only apply date filter for topic-based analysis without a specific URL.
if [ -n "$URL" ]; then
PERIOD="${PERIOD:-}"
FROM_DATE=""
TO_DATE=""
if [ -n "$PERIOD" ]; then
period_to_dates "$PERIOD"
fi
else
PERIOD="${PERIOD:-today}"
period_to_dates "$PERIOD"
fi
# Build prompt
_target=""
if [ -n "$URL" ]; then
_target="Post/thread URL: $URL. Fetch this specific post and its full reply thread."
fi
if [ -n "$QUERY" ]; then
if [ -n "$_target" ]; then
_target="$_target Context: $QUERY"
else
_target="Topic/post: $QUERY"
fi
fi
_period_hint=""
if [ -n "$FROM_DATE" ]; then
_period_hint=" Period for replies/discussion: from $FROM_DATE to $TO_DATE."
fi
_prompt="Find and analyze this X post/thread/topic: ${_target}.${_period_hint}
Provide a thorough analysis:
1. **Main thesis** — what is the core message or claim
2. **Key arguments** — supporting points, evidence, data cited
3. **Community reaction** — overall tone (positive/negative/mixed), main counter-arguments, notable replies
4. **Interesting findings** — unexpected insights, unique perspectives from the discussion
5. **Sentiment breakdown** — approximate ratio of supportive vs critical vs neutral reactions
6. **Key voices** — notable accounts that engaged with this topic
7. **Telegram post angle** — 2-3 concrete ideas for how to turn this into an engaging Telegram post"
# Check --prefer-cache
if [ -n "$PREFER_CACHE" ]; then
_cached=$("$SCRIPT_DIR/find_latest.sh" --script analyze --query "$QUERY" --url "${URL:-}" --period "${PERIOD:-}" 2>/dev/null || true)
if [ -n "$_cached" ] && [ -f "$_cached" ]; then
echo "=== X Analysis (cached) ===" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
print(data.get('text', ''))
" "$_cached" | print_head "$LIMIT"
echo "Artifact: $_cached" >&2
exit 0
fi
fi
# No structured output for analyze — free-form text is better for deep analysis
# When URL given without period, skip date filters so x_search can find the post regardless of age
grok_search "$_prompt" "" "${FROM_DATE:-}" "${TO_DATE:-}"
# Parse as plain text (not structured items)
_parsed=$(python3 -c "
import json, sys
data = json.load(sys.stdin)
text_parts = []
citations = []
for item in data.get('output', []):
if item.get('type') == 'message':
for c in item.get('content', []):
if c.get('type') == 'output_text':
text_parts.append(c['text'])
for ann in c.get('annotations', []):
if ann.get('type') == 'url_citation':
citations.append(ann.get('url', ''))
result = {
'items': [],
'ideas': [],
'summary': '',
'text': '\\n'.join(text_parts),
'citations': sorted(set(c for c in citations if c))
}
print(json.dumps(result, ensure_ascii=False, indent=2))
" < "$_RESPONSE_FILE")
# Save artifact
_extra=$(_QUERY="$QUERY" _URL="${URL:-}" _PERIOD="${PERIOD:-}" _FROM="${FROM_DATE:-}" _TO="${TO_DATE:-}" python3 -c "
import json, os
print(json.dumps({
'query': os.environ.get('_QUERY', ''),
'url': os.environ.get('_URL', ''),
'period': os.environ.get('_PERIOD', ''),
'from_date': os.environ.get('_FROM', ''),
'to_date': os.environ.get('_TO', ''),
}))")
_artifact=$(save_artifact "analyze" "$_parsed" "$_extra")
# Output
echo "=== X Analysis: $PERIOD ===" >&2
python3 -c "
import json, sys
data = json.loads(sys.argv[1])
text = data.get('text', '')
if text:
print(text)
citations = data.get('citations', [])
if citations:
print()
print('--- Sources ---')
for u in citations:
print(u)
" "$_parsed" | print_head "$LIMIT"
echo "" >&2
echo "Artifact: $_artifact" >&2
#!/bin/sh
# Common functions for X Research skill
# Dependencies: curl, python3 (stdlib only)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$SKILL_DIR/config/.env"
CACHE_DIR="$SKILL_DIR/cache"
RUNS_DIR="$CACHE_DIR/runs"
INDEX_FILE="$CACHE_DIR/index.jsonl"
XAI_API_URL="https://api.x.ai/v1/responses"
XAI_MODEL_FALLBACK="grok-4.20-reasoning"
MAX_HANDLES_PER_BATCH=10
# --------------- Prerequisites ---------------
check_python3() {
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required but not found." >&2
echo "Install Python 3.7+ and ensure python3 is in PATH." >&2
exit 1
fi
}
# --------------- Config ---------------
load_config() {
check_python3
if [ -f "$CONFIG_FILE" ]; then
if [ -f "$SCRIPT_DIR/sanitize_env.sh" ]; then
sh "$SCRIPT_DIR/sanitize_env.sh" "$CONFIG_FILE"
fi
_exports_file=$(mktemp "${TMPDIR:-/tmp}/xr_env.XXXXXX")
if ! python3 - "$CONFIG_FILE" > "$_exports_file" <<'PY'
import pathlib
import re
import shlex
import sys
path = pathlib.Path(sys.argv[1])
key_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not raw_line.strip() or raw_line.lstrip().startswith("#") or "=" not in raw_line:
continue
key, value = raw_line.split("=", 1)
key = key.strip()
if not key_pattern.fullmatch(key):
continue
value = value.strip()
quoted = False
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
quoted = True
print(f"export {key}={shlex.quote(value)}")
if not quoted and any(ch.isspace() for ch in value):
print(
f"Warning: {path.name}:{lineno} {key} contains spaces without quotes. "
"Quote the value to keep config/.env shell-compatible.",
file=sys.stderr,
)
PY
then
rm -f "$_exports_file"
echo "Error: failed to parse $CONFIG_FILE." >&2
exit 1
fi
# Source a sanitized export file instead of eval'ing raw .env content.
# This keeps the loader tolerant of unquoted spaces and avoids executing config text.
# shellcheck disable=SC1090
. "$_exports_file"
rm -f "$_exports_file"
fi
if [ -z "$XAI_API_KEY" ]; then
echo "Error: XAI_API_KEY not set. Copy config/.env.example to config/.env and add your key." >&2
exit 1
fi
XAI_MODEL="${XAI_MODEL:-grok-4-1-fast-reasoning}"
X_DEFAULT_CATEGORY="${X_DEFAULT_CATEGORY:-ai}"
mkdir -p "$RUNS_DIR"
}
# --------------- Input normalization ---------------
normalize_handle() {
printf '%s' "$1" | sed 's/^@//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
normalize_handles() {
_result=""
_old_ifs="$IFS"
IFS=','
for _h in $1; do
_h=$(normalize_handle "$_h")
if [ -n "$_h" ]; then
if [ -n "$_result" ]; then
_result="$_result,$_h"
else
_result="$_h"
fi
fi
done
IFS="$_old_ifs"
printf '%s' "$_result"
}
# --------------- Parameter parsing ---------------
parse_common_params() {
ACCOUNTS=""
CATEGORY=""
PERIOD=""
QUERY=""
TOPICS=""
LIMIT="30"
PREFER_CACHE=""
REFRESH=""
URL=""
while [ $# -gt 0 ]; do
case "$1" in
--accounts) ACCOUNTS="$2"; shift 2 ;;
--category) CATEGORY="$2"; shift 2 ;;
--period) PERIOD="$2"; shift 2 ;;
--query) QUERY="$2"; shift 2 ;;
--topics) TOPICS="$2"; shift 2 ;;
--limit) LIMIT="$2"; shift 2 ;;
--url) URL="$2"; shift 2 ;;
--prefer-cache) PREFER_CACHE="1"; shift ;;
--refresh) REFRESH="1"; shift ;;
*) shift ;;
esac
done
}
# --------------- Category resolution ---------------
# Validate category ID: only [A-Za-z0-9_] allowed (prevents shell injection via eval)
validate_category() {
_input="$1"
_clean=$(printf '%s' "$_input" | sed 's/[^A-Za-z0-9_]//g')
if [ "$_input" != "$_clean" ] || [ -z "$_input" ]; then
echo "Error: invalid category '$_input'. Only letters, digits, and underscores allowed." >&2
exit 1
fi
}
resolve_accounts() {
if [ -n "$ACCOUNTS" ]; then
ACCOUNTS=$(normalize_handles "$ACCOUNTS")
return
fi
_cat="${CATEGORY:-$X_DEFAULT_CATEGORY}"
validate_category "$_cat"
_cat_upper=$(printf '%s' "$_cat" | tr '[:lower:]' '[:upper:]')
eval "_accts=\${X_ACCOUNTS_${_cat_upper}:-}"
if [ -n "$_accts" ]; then
ACCOUNTS=$(normalize_handles "$_accts")
CATEGORY="$_cat"
else
echo "Error: no accounts for category '$_cat'. Pass --accounts or configure X_ACCOUNTS_${_cat_upper} in .env." >&2
exit 1
fi
}
resolve_topics() {
if [ -n "$TOPICS" ]; then
return
fi
_cat="${CATEGORY:-$X_DEFAULT_CATEGORY}"
validate_category "$_cat"
_cat_upper=$(printf '%s' "$_cat" | tr '[:lower:]' '[:upper:]')
eval "_topics=\${X_TOPICS_${_cat_upper}:-}"
if [ -n "$_topics" ]; then
TOPICS="$_topics"
CATEGORY="$_cat"
else
echo "Error: no topics for category '$_cat'. Pass --topics or configure X_TOPICS_${_cat_upper} in .env." >&2
exit 1
fi
}
# --------------- Period handling ---------------
period_to_dates() {
_period="${1:-1d}"
_now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
_today=$(date -u +%Y-%m-%d)
case "$_period" in
1h)
# 1 hour ago
_from=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "1 hour ago" +%Y-%m-%dT%H:%M:%SZ)
;;
today)
_from="${_today}T00:00:00Z"
;;
yesterday)
_yd=$(date -u -v-1d +%Y-%m-%d 2>/dev/null || date -u -d "yesterday" +%Y-%m-%d)
_from="${_yd}T00:00:00Z"
_now_iso="${_today}T00:00:00Z"
;;
week|7d)
_wd=$(date -u -v-7d +%Y-%m-%d 2>/dev/null || date -u -d "7 days ago" +%Y-%m-%d)
_from="${_wd}T00:00:00Z"
;;
*d)
_n=$(printf '%s' "$_period" | sed 's/d$//')
_nd=$(date -u -v-"${_n}d" +%Y-%m-%d 2>/dev/null || date -u -d "$_n days ago" +%Y-%m-%d)
_from="${_nd}T00:00:00Z"
;;
*)
_from="${_today}T00:00:00Z"
;;
esac
FROM_DATE="$_from"
TO_DATE="$_now_iso"
}
# --------------- Grok API ---------------
# grok_search <prompt> [handles] [from_date] [to_date] [system_prompt]
# Writes response text to stdout, saves artifact.
# Sets: _RESPONSE_FILE, _ARTIFACT_FILE
grok_search() {
_prompt="$1"
_handles="${2:-}"
_from_date="${3:-}"
_to_date="${4:-}"
_system_prompt="${5:-}"
_body_file="${TMPDIR:-/tmp}/xr_body_$$.json"
_response_file="${TMPDIR:-/tmp}/xr_response_$$.json"
_headers_file="${TMPDIR:-/tmp}/xr_headers_$$.txt"
trap 'rm -f "$_body_file" "$_response_file" "$_headers_file"' EXIT
# Build request body via python3
_XR_PROMPT="$_prompt" \
_XR_MODEL="$XAI_MODEL" \
_XR_HANDLES="$_handles" \
_XR_FROM="$_from_date" \
_XR_TO="$_to_date" \
_XR_SYSTEM="$_system_prompt" \
python3 -c "
import json, os
tool = {'type': 'x_search'}
h = os.environ.get('_XR_HANDLES', '')
if h:
tool['allowed_x_handles'] = [x.strip() for x in h.split(',') if x.strip()]
fd = os.environ.get('_XR_FROM', '')
if fd:
tool['from_date'] = fd
td = os.environ.get('_XR_TO', '')
if td:
tool['to_date'] = td
messages = []
sys_p = os.environ.get('_XR_SYSTEM', '')
if sys_p:
messages.append({'role': 'developer', 'content': sys_p})
messages.append({'role': 'user', 'content': os.environ['_XR_PROMPT']})
body = {
'model': os.environ['_XR_MODEL'],
'input': messages,
'tools': [tool]
}
print(json.dumps(body, ensure_ascii=False))
" > "$_body_file"
# API call
_http_code=$(curl -s -o "$_response_file" -w '%{http_code}' \
-X POST "$XAI_API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XAI_API_KEY" \
-d @"$_body_file")
# Model fallback on 422/400
if [ "$_http_code" = "422" ] || [ "$_http_code" = "400" ]; then
_err_text=$(cat "$_response_file" 2>/dev/null || true)
case "$_err_text" in
*"model"*|*"not found"*|*"unavailable"*|*"not_found"*)
echo "Warning: model '$XAI_MODEL' unavailable ($_http_code), trying fallback '$XAI_MODEL_FALLBACK'..." >&2
XAI_MODEL="$XAI_MODEL_FALLBACK"
_XR_PROMPT="$_prompt" \
_XR_MODEL="$XAI_MODEL" \
_XR_HANDLES="$_handles" \
_XR_FROM="$_from_date" \
_XR_TO="$_to_date" \
_XR_SYSTEM="$_system_prompt" \
python3 -c "
import json, os
tool = {'type': 'x_search'}
h = os.environ.get('_XR_HANDLES', '')
if h:
tool['allowed_x_handles'] = [x.strip() for x in h.split(',') if x.strip()]
fd = os.environ.get('_XR_FROM', '')
if fd:
tool['from_date'] = fd
td = os.environ.get('_XR_TO', '')
if td:
tool['to_date'] = td
messages = []
sys_p = os.environ.get('_XR_SYSTEM', '')
if sys_p:
messages.append({'role': 'developer', 'content': sys_p})
messages.append({'role': 'user', 'content': os.environ['_XR_PROMPT']})
body = {
'model': os.environ['_XR_MODEL'],
'input': messages,
'tools': [tool]
}
print(json.dumps(body, ensure_ascii=False))
" > "$_body_file"
_http_code=$(curl -s -o "$_response_file" -w '%{http_code}' \
-X POST "$XAI_API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XAI_API_KEY" \
-d @"$_body_file")
;;
esac
fi
# Check final status
if [ "$_http_code" -lt 200 ] || [ "$_http_code" -ge 300 ]; then
echo "Error: API returned HTTP $_http_code" >&2
cat "$_response_file" >&2
exit 1
fi
_RESPONSE_FILE="$_response_file"
}
# Extract text + citations from response JSON
# Usage: parse_response < response.json
parse_response() {
python3 -c "
import json, sys
data = json.load(sys.stdin)
text_parts = []
citations = []
for item in data.get('output', []):
if item.get('type') == 'message':
for c in item.get('content', []):
if c.get('type') == 'output_text':
text_parts.append(c['text'])
for ann in c.get('annotations', []):
if ann.get('type') == 'url_citation':
citations.append(ann.get('url', ''))
text = '\\n'.join(text_parts)
print(text)
if citations:
print('\\n--- Sources ---')
for u in sorted(set(citations)):
if u:
print(u)
"
}
# Parse structured JSON items from response text
# Usage: parse_items < response.json → writes JSON to stdout
# Returns: {"items": [...], "ideas": [...], "summary": "...", "text": "...", "citations": [...]}
parse_structured_response() {
python3 -c "
import json, sys
data = json.load(sys.stdin)
text_parts = []
citations = []
for item in data.get('output', []):
if item.get('type') == 'message':
for c in item.get('content', []):
if c.get('type') == 'output_text':
text_parts.append(c['text'])
for ann in c.get('annotations', []):
if ann.get('type') == 'url_citation':
citations.append(ann.get('url', ''))
raw_text = '\\n'.join(text_parts)
# Try to parse as structured JSON
try:
structured = json.loads(raw_text)
items = structured.get('items', [])
ideas = structured.get('ideas', [])
summary = structured.get('summary', '')
except (json.JSONDecodeError, AttributeError):
# Fallback: plain text
items = []
ideas = []
summary = ''
print('Warning: could not parse structured JSON from model response, using plain text', file=sys.stderr)
result = {
'items': items,
'ideas': ideas,
'summary': summary,
'text': raw_text,
'citations': sorted(set(c for c in citations if c))
}
print(json.dumps(result, ensure_ascii=False, indent=2))
"
}
# --------------- Artifact store ---------------
# generate short run ID (6 hex chars from PID + timestamp)
gen_run_id() {
printf '%s%s' "$$" "$(date +%s)" | cksum | awk '{printf "%06x", $1 % 16777216}'
}
# save_artifact <script_name> <parsed_json> [extra_meta_json]
# Writes artifact to cache/runs/ and appends to index.jsonl
# Prints artifact path to stdout
save_artifact() {
_script_name="$1"
_parsed_json="$2"
_extra_meta="${3:-\{\}}"
_run_id=$(gen_run_id)
_ts=$(date -u +%Y%m%d_%H%M%S)
_ts_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
_artifact_file="$RUNS_DIR/${_script_name}_${_ts}_${_run_id}.json"
# Merge meta + parsed data into artifact
_XR_SCRIPT="$_script_name" \
_XR_RUN_ID="$_run_id" \
_XR_TS="$_ts_iso" \
_XR_MODEL_USED="$XAI_MODEL" \
_XR_EXTRA="$_extra_meta" \
_XR_PARSED="$_parsed_json" \
python3 -c "
import json, os, sys
parsed = json.loads(os.environ['_XR_PARSED'])
extra = json.loads(os.environ['_XR_EXTRA'])
meta = {
'script': os.environ['_XR_SCRIPT'],
'run_id': os.environ['_XR_RUN_ID'],
'created_at': os.environ['_XR_TS'],
'model': os.environ['_XR_MODEL_USED'],
}
meta.update(extra)
artifact = {'meta': meta}
artifact.update(parsed)
print(json.dumps(artifact, ensure_ascii=False, indent=2))
" > "$_artifact_file"
# Append to index
_XR_PATH="runs/${_script_name}_${_ts}_${_run_id}.json" \
_XR_EXTRA="$_extra_meta" \
_XR_SCRIPT="$_script_name" \
_XR_RUN_ID="$_run_id" \
_XR_TS="$_ts_iso" \
_XR_MODEL_USED="$XAI_MODEL" \
python3 -c "
import json, os
extra = json.loads(os.environ['_XR_EXTRA'])
entry = {
'run_id': os.environ['_XR_RUN_ID'],
'script': os.environ['_XR_SCRIPT'],
'created_at': os.environ['_XR_TS'],
'model': os.environ['_XR_MODEL_USED'],
'path': os.environ['_XR_PATH'],
}
entry.update(extra)
print(json.dumps(entry, ensure_ascii=False))
" >> "$INDEX_FILE"
printf '%s' "$_artifact_file"
}
# --------------- Output ---------------
print_head() {
_n="${1:-30}"
_total=0
_printed=0
_tmpcount="${TMPDIR:-/tmp}/xr_count_$$.txt"
while IFS= read -r _line; do
_total=$(( _total + 1 ))
if [ "$_total" -le "$_n" ]; then
printf '%s\n' "$_line"
_printed=$(( _printed + 1 ))
fi
done
if [ "$_total" -gt "$_n" ]; then
echo "... $(( _total - _n )) more lines in artifact file"
fi
rm -f "$_tmpcount"
}
#!/bin/sh
# Digest: summarize latest posts from subscribed X accounts
# Usage: bash scripts/digest.sh [--category ai] [--accounts "user1,user2"] [--period today]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
resolve_accounts
PERIOD="${PERIOD:-today}"
period_to_dates "$PERIOD"
# Check --prefer-cache BEFORE batch/single split
if [ -n "$PREFER_CACHE" ]; then
_cached=$("$SCRIPT_DIR/find_latest.sh" --script digest --category "${CATEGORY:-}" --accounts "$ACCOUNTS" --period "$PERIOD" 2>/dev/null || true)
if [ -n "$_cached" ] && [ -f "$_cached" ]; then
echo "=== X Digest (cached): $PERIOD ===" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
text = data.get('text', data.get('summary', ''))
if text:
print(text)
items = data.get('items', [])
if items:
print()
for it in items:
print(f\" @{it.get('author','')} | {it.get('date','')} | {it.get('summary','')}\")
ideas = data.get('ideas', [])
if ideas:
print()
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_cached" | print_head "$LIMIT"
echo "Artifact: $_cached" >&2
exit 0
fi
fi
SYSTEM_PROMPT='You are an X/Twitter research assistant. Always respond with valid JSON matching this schema:
{"items": [{"author": "@handle", "date": "ISO8601", "summary": "1-2 sentence summary", "engagement": "likes, reposts", "url": "https://x.com/...", "topic": "category"}], "ideas": ["Telegram post idea 1", "..."], "summary": "Overall narrative in 2-3 sentences"}
Do NOT wrap in markdown code fences. Return raw JSON only.'
# Count handles for batching (awk -F, counts fields correctly, wc -l is off-by-one)
_handle_count=$(printf '%s' "$ACCOUNTS" | awk -F, '{print NF}')
if [ "$_handle_count" -le "$MAX_HANDLES_PER_BATCH" ]; then
# Single batch
_prompt="Search X for the latest posts from these accounts: $ACCOUNTS. Period: from $FROM_DATE to $TO_DATE. For each notable post provide: author handle, date, summary (1-2 sentences), engagement metrics, post URL, topic. Highlight: breaking news, unique insights, hot takes, data/research. Suggest 3-5 Telegram post ideas based on the digest."
grok_search "$_prompt" "$ACCOUNTS" "$FROM_DATE" "$TO_DATE" "$SYSTEM_PROMPT"
_parsed=$(parse_structured_response < "$_RESPONSE_FILE")
else
# Batch mode: split handles into groups of MAX_HANDLES_PER_BATCH
_merge_dir="${TMPDIR:-/tmp}/xr_merge_$$"
mkdir -p "$_merge_dir"
trap 'rm -rf "$_merge_dir"' EXIT
_batch_num=0
_current_batch=""
_current_count=0
_old_ifs="$IFS"
IFS=','
for _h in $ACCOUNTS; do
_h=$(printf '%s' "$_h" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$_h" ] && continue
if [ "$_current_count" -ge "$MAX_HANDLES_PER_BATCH" ]; then
_prompt="Search X for the latest posts from these accounts: $_current_batch. Period: from $FROM_DATE to $TO_DATE. For each notable post provide: author handle, date, summary, engagement, post URL, topic."
grok_search "$_prompt" "$_current_batch" "$FROM_DATE" "$TO_DATE" "$SYSTEM_PROMPT"
parse_structured_response < "$_RESPONSE_FILE" > "$_merge_dir/batch_${_batch_num}.json"
_batch_num=$(( _batch_num + 1 ))
_current_batch=""
_current_count=0
fi
if [ -n "$_current_batch" ]; then
_current_batch="$_current_batch,$_h"
else
_current_batch="$_h"
fi
_current_count=$(( _current_count + 1 ))
done
IFS="$_old_ifs"
# Run last batch
if [ -n "$_current_batch" ]; then
_prompt="Search X for the latest posts from these accounts: $_current_batch. Period: from $FROM_DATE to $TO_DATE. For each notable post provide: author handle, date, summary, engagement, post URL, topic."
grok_search "$_prompt" "$_current_batch" "$FROM_DATE" "$TO_DATE" "$SYSTEM_PROMPT"
parse_structured_response < "$_RESPONSE_FILE" > "$_merge_dir/batch_${_batch_num}.json"
fi
# Merge batches
_parsed=$(python3 -c "
import json, glob, sys, os
merge_dir = sys.argv[1]
all_items = []
all_ideas = []
all_citations = []
all_text = []
seen_urls = set()
for f in sorted(glob.glob(os.path.join(merge_dir, 'batch_*.json'))):
data = json.load(open(f))
for item in data.get('items', []):
url = item.get('url', '')
if url and url in seen_urls:
continue
if url:
seen_urls.add(url)
all_items.append(item)
all_ideas.extend(data.get('ideas', []))
all_citations.extend(data.get('citations', []))
if data.get('text'):
all_text.append(data['text'])
# Sort items by date descending
all_items.sort(key=lambda x: x.get('date', ''), reverse=True)
result = {
'items': all_items,
'ideas': list(dict.fromkeys(all_ideas)),
'summary': '',
'text': '\\n---\\n'.join(all_text),
'citations': sorted(set(c for c in all_citations if c))
}
print(json.dumps(result, ensure_ascii=False, indent=2))
" "$_merge_dir")
fi
# Save artifact
_extra=$(_CAT="${CATEGORY:-}" _HANDLES="$ACCOUNTS" _PERIOD="$PERIOD" _FROM="$FROM_DATE" _TO="$TO_DATE" python3 -c "
import json, os
print(json.dumps({
'category': os.environ.get('_CAT', ''),
'handles': [h for h in os.environ.get('_HANDLES', '').split(',') if h],
'period': os.environ.get('_PERIOD', ''),
'from_date': os.environ.get('_FROM', ''),
'to_date': os.environ.get('_TO', ''),
}))")
_artifact=$(save_artifact "digest" "$_parsed" "$_extra")
# Output
echo "=== X Digest: $PERIOD (${FROM_DATE} — ${TO_DATE}) ===" >&2
python3 -c "
import json, sys
data = json.loads(sys.argv[1])
items = data.get('items', [])
if items:
for it in items:
author = it.get('author', '?')
date = it.get('date', '')
summary = it.get('summary', '')
engagement = it.get('engagement', '')
print(f' {author} | {date} | {summary} | {engagement}')
else:
text = data.get('text', '')
if text:
print(text)
ideas = data.get('ideas', [])
if ideas:
print()
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_parsed" | print_head "$LIMIT"
echo "" >&2
echo "Artifact: $_artifact" >&2
#!/bin/sh
# Find latest matching artifact from the index
# Usage: bash scripts/find_latest.sh --script digest [--category ai] [--query "..."] [--period today] [--accounts "a,b"] [--topics "X,Y"] [--url "..."]
# Prints: absolute path to latest matching artifact, or exits 1 if none found
# All provided filters must match; omitted filters are ignored.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CACHE_DIR="$SKILL_DIR/cache"
INDEX_FILE="$CACHE_DIR/index.jsonl"
# Parse args
_script=""
_category=""
_query=""
_period=""
_accounts=""
_topics=""
_url=""
while [ $# -gt 0 ]; do
case "$1" in
--script) _script="$2"; shift 2 ;;
--category) _category="$2"; shift 2 ;;
--query) _query="$2"; shift 2 ;;
--period) _period="$2"; shift 2 ;;
--accounts) _accounts="$2"; shift 2 ;;
--topics) _topics="$2"; shift 2 ;;
--url) _url="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$_script" ]; then
echo "Error: --script is required." >&2
exit 1
fi
if [ ! -f "$INDEX_FILE" ]; then
exit 1
fi
_FIND_SCRIPT="$_script" \
_FIND_CATEGORY="$_category" \
_FIND_QUERY="$_query" \
_FIND_PERIOD="$_period" \
_FIND_ACCOUNTS="$_accounts" \
_FIND_TOPICS="$_topics" \
_FIND_URL="$_url" \
_FIND_CACHE="$CACHE_DIR" \
python3 -c "
import json, os, sys
cache_dir = os.environ['_FIND_CACHE']
index_file = os.path.join(cache_dir, 'index.jsonl')
# Filters from env
filters = {}
for key in ('script', 'category', 'query', 'period', 'url'):
val = os.environ.get(f'_FIND_{key.upper()}', '')
if val:
filters[key] = val
# Handles: compare as sorted sets
accounts_filter = os.environ.get('_FIND_ACCOUNTS', '')
if accounts_filter:
filters['_handles_set'] = sorted(set(h.strip() for h in accounts_filter.split(',') if h.strip()))
# Topics: compare as string
topics_filter = os.environ.get('_FIND_TOPICS', '')
if topics_filter:
filters['topics'] = topics_filter
matches = []
with open(index_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
ok = True
for key, val in filters.items():
if key == '_handles_set':
entry_handles = entry.get('handles', [])
if isinstance(entry_handles, list):
entry_set = sorted(set(entry_handles))
else:
entry_set = sorted(set(h.strip() for h in str(entry_handles).split(',') if h.strip()))
if entry_set != val:
ok = False
break
else:
if entry.get(key, '') != val:
ok = False
break
if ok:
matches.append(entry)
if not matches:
sys.exit(1)
matches.sort(key=lambda x: x.get('created_at', ''), reverse=True)
best = matches[0]
path = os.path.join(cache_dir, best['path'])
if os.path.isfile(path):
print(path)
else:
sys.exit(1)
"
#!/bin/sh
# Copy the skill to a writable runtime directory before running scripts.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_NAME="$(basename "$SKILL_DIR")"
RUN_DIR="${1:-/home/claude/$SKILL_NAME}"
RUN_PARENT="$(dirname "$RUN_DIR")"
RUN_BASE="$(basename "$RUN_DIR")"
mkdir -p "$RUN_PARENT"
RUN_PARENT="$(cd "$RUN_PARENT" && pwd)"
RUN_DIR="$RUN_PARENT/$RUN_BASE"
if [ "$SKILL_DIR" != "$RUN_DIR" ]; then
mkdir -p "$RUN_DIR"
cp -R "$SKILL_DIR"/. "$RUN_DIR"/
fi
if [ -f "$RUN_DIR/config/.env" ]; then
sh "$RUN_DIR/scripts/sanitize_env.sh" "$RUN_DIR/config/.env"
fi
printf '%s\n' "$RUN_DIR"
#!/bin/sh
# Quote unquoted .env values that contain spaces before any shell-style loading.
set -e
ENV_FILE="${1:-config/.env}"
if [ ! -f "$ENV_FILE" ]; then
exit 0
fi
SED_SKIP_COMMENT='/^[[:space:]]*#/b'
SED_SKIP_EMPTY='/^[[:space:]]*$/b'
SED_QUOTE_VALUE='s/^([[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[[:space:]]*)([^"'\''#][^#]*[[:space:]][^#]*)([[:space:]]*(#.*)?)$/\1"\3"\4/'
if sed --version >/dev/null 2>&1; then
sed -i -E -e "$SED_SKIP_COMMENT" -e "$SED_SKIP_EMPTY" -e "$SED_QUOTE_VALUE" "$ENV_FILE"
else
sed -i '' -E -e "$SED_SKIP_COMMENT" -e "$SED_SKIP_EMPTY" -e "$SED_QUOTE_VALUE" "$ENV_FILE"
fi
#!/bin/sh
# Search: custom query search on X
# Usage: bash scripts/search.sh --query "что думают про Claude 4" [--period week] [--accounts "user1,user2"]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
if [ -z "$QUERY" ]; then
echo "Error: --query is required." >&2
echo "Usage: bash scripts/search.sh --query \"search query\" [--period week] [--accounts \"user1,user2\"]" >&2
exit 1
fi
PERIOD="${PERIOD:-today}"
period_to_dates "$PERIOD"
# Normalize optional accounts filter
_handles=""
if [ -n "$ACCOUNTS" ]; then
_handles=$(normalize_handles "$ACCOUNTS")
fi
SYSTEM_PROMPT='You are an X/Twitter research assistant. Always respond with valid JSON matching this schema:
{"items": [{"author": "@handle", "date": "ISO8601", "summary": "1-2 sentence summary", "engagement": "likes, reposts", "url": "https://x.com/...", "topic": "category"}], "ideas": ["Telegram post idea 1", "..."], "summary": "Overall search results narrative in 2-3 sentences"}
Do NOT wrap in markdown code fences. Return raw JSON only.'
_prompt="Search X for: $QUERY. Period: from $FROM_DATE to $TO_DATE.
Analyze the results and provide:
- Main narratives and themes in the discussion
- Key influencer opinions and takes
- Community sentiment (supportive vs critical)
- Most engaged/viral posts
- Unexpected or contrarian perspectives
- 3-5 concrete Telegram post ideas based on findings"
# Check --prefer-cache
if [ -n "$PREFER_CACHE" ]; then
_cached=$("$SCRIPT_DIR/find_latest.sh" --script search --query "$QUERY" --period "$PERIOD" --accounts "$_handles" 2>/dev/null || true)
if [ -n "$_cached" ] && [ -f "$_cached" ]; then
echo "=== X Search (cached): $QUERY ===" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
items = data.get('items', [])
if items:
for it in items:
author = it.get('author', '?')
summary = it.get('summary', '')
engagement = it.get('engagement', '')
print(f' {author} | {summary} | {engagement}')
else:
print(data.get('text', ''))
ideas = data.get('ideas', [])
if ideas:
print()
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_cached" | print_head "$LIMIT"
echo "Artifact: $_cached" >&2
exit 0
fi
fi
grok_search "$_prompt" "$_handles" "$FROM_DATE" "$TO_DATE" "$SYSTEM_PROMPT"
_parsed=$(parse_structured_response < "$_RESPONSE_FILE")
# Save artifact
_extra=$(_QUERY="$QUERY" _HANDLES="$_handles" _PERIOD="$PERIOD" _FROM="$FROM_DATE" _TO="$TO_DATE" python3 -c "
import json, os
handles = os.environ.get('_HANDLES', '')
print(json.dumps({
'query': os.environ.get('_QUERY', ''),
'handles': [h for h in handles.split(',') if h] if handles else None,
'period': os.environ.get('_PERIOD', ''),
'from_date': os.environ.get('_FROM', ''),
'to_date': os.environ.get('_TO', ''),
}))")
_artifact=$(save_artifact "search" "$_parsed" "$_extra")
# Output
echo "=== X Search: '$QUERY' ($PERIOD) ===" >&2
python3 -c "
import json, sys
data = json.loads(sys.argv[1])
items = data.get('items', [])
summary = data.get('summary', '')
if summary:
print(summary)
print()
if items:
for it in items:
author = it.get('author', '?')
date = it.get('date', '')
summary_item = it.get('summary', '')
engagement = it.get('engagement', '')
print(f' {author} | {date} | {summary_item} | {engagement}')
else:
text = data.get('text', '')
if text:
print(text)
ideas = data.get('ideas', [])
if ideas:
print()
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_parsed" | print_head "$LIMIT"
echo "" >&2
echo "Artifact: $_artifact" >&2
#!/bin/sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sh "$SCRIPT_DIR/test_config_loader.sh"
echo "x-research tests passed"
#!/bin/sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REAL_SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMMON_SH="$REAL_SKILL_DIR/scripts/common.sh"
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/xr_config_test.XXXXXX")
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT INT TERM
cat > "$TMP_DIR/.env" <<'EOF'
XAI_API_KEY=test-key
XAI_MODEL=grok-test
X_DEFAULT_CATEGORY=openai
X_TOPICS_OPENAI=GPT,ChatGPT,OpenAI,Codex,gpt 5.4
X_TOPICS_ANTHROPIC=Claude,Anthropic,ClaudeCode,Claude Code
X_ACCOUNTS_OPENAI_LABEL="OpenAI ecosystem"
X_ACCOUNTS_OPENAI=sama,OpenAI
EOF
# shellcheck disable=SC1090
. "$COMMON_SH"
SCRIPT_DIR="$REAL_SKILL_DIR/scripts"
CONFIG_FILE="$TMP_DIR/.env"
CACHE_DIR="$TMP_DIR/cache"
RUNS_DIR="$CACHE_DIR/runs"
INDEX_FILE="$CACHE_DIR/index.jsonl"
load_config >/dev/null
[ "$XAI_API_KEY" = "test-key" ]
[ "$XAI_MODEL" = "grok-test" ]
[ "$X_DEFAULT_CATEGORY" = "openai" ]
[ "$X_TOPICS_OPENAI" = "GPT,ChatGPT,OpenAI,Codex,gpt 5.4" ]
[ "$X_TOPICS_ANTHROPIC" = "Claude,Anthropic,ClaudeCode,Claude Code" ]
[ "$X_ACCOUNTS_OPENAI_LABEL" = "OpenAI ecosystem" ]
[ "$X_ACCOUNTS_OPENAI" = "sama,OpenAI" ]
grep -q '^X_TOPICS_OPENAI="GPT,ChatGPT,OpenAI,Codex,gpt 5.4"$' "$TMP_DIR/.env"
grep -q '^X_TOPICS_ANTHROPIC="Claude,Anthropic,ClaudeCode,Claude Code"$' "$TMP_DIR/.env"
#!/bin/sh
# Trending: find hot topics and discussions on X by interest areas
# Usage: bash scripts/trending.sh [--category ai] [--topics "AI,LLM"] [--period today]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
resolve_topics
PERIOD="${PERIOD:-today}"
period_to_dates "$PERIOD"
SYSTEM_PROMPT='You are an X/Twitter research assistant. Always respond with valid JSON matching this schema:
{"items": [{"topic": "topic name", "summary": "2-3 sentence description of the discussion", "sentiment": "positive/negative/mixed/neutral", "key_voices": ["@handle1", "@handle2"], "engagement": "approximate scale", "url": "most relevant post URL"}], "ideas": ["Telegram post idea 1", "..."], "summary": "Overall trending narrative in 2-3 sentences"}
Do NOT wrap in markdown code fences. Return raw JSON only.'
_prompt="Search X for the most discussed and interesting topics about: $TOPICS. Period: from $FROM_DATE to $TO_DATE.
Find and analyze:
- Breaking news and announcements
- Hot debates and controversies
- Viral posts and threads
- Expert opinions and predictions
- Unexpected findings or contrarian takes
- Data, research, or reports being shared
For each notable topic/discussion: summarize the core narrative, assess sentiment, identify key voices, and suggest a Telegram post angle."
# Check --prefer-cache
if [ -n "$PREFER_CACHE" ]; then
_cached=$("$SCRIPT_DIR/find_latest.sh" --script trending --category "${CATEGORY:-}" --topics "$TOPICS" --period "$PERIOD" 2>/dev/null || true)
if [ -n "$_cached" ] && [ -f "$_cached" ]; then
echo "=== X Trending (cached): $TOPICS ===" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
items = data.get('items', [])
if items:
for it in items:
topic = it.get('topic', '?')
summary = it.get('summary', '')
sentiment = it.get('sentiment', '')
print(f' [{sentiment}] {topic}: {summary}')
else:
print(data.get('text', ''))
ideas = data.get('ideas', [])
if ideas:
print()
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_cached" | print_head "$LIMIT"
echo "Artifact: $_cached" >&2
exit 0
fi
fi
grok_search "$_prompt" "" "$FROM_DATE" "$TO_DATE" "$SYSTEM_PROMPT"
_parsed=$(parse_structured_response < "$_RESPONSE_FILE")
# Save artifact
_extra=$(_CAT="${CATEGORY:-}" _TOPICS="$TOPICS" _PERIOD="$PERIOD" _FROM="$FROM_DATE" _TO="$TO_DATE" python3 -c "
import json, os
print(json.dumps({
'category': os.environ.get('_CAT', ''),
'topics': os.environ.get('_TOPICS', ''),
'period': os.environ.get('_PERIOD', ''),
'from_date': os.environ.get('_FROM', ''),
'to_date': os.environ.get('_TO', ''),
}))")
_artifact=$(save_artifact "trending" "$_parsed" "$_extra")
# Output
echo "=== X Trending: $TOPICS ($PERIOD) ===" >&2
python3 -c "
import json, sys
data = json.loads(sys.argv[1])
items = data.get('items', [])
if items:
for it in items:
topic = it.get('topic', '?')
summary = it.get('summary', '')
sentiment = it.get('sentiment', '')
voices = ', '.join(it.get('key_voices', [])[:3])
print(f' [{sentiment}] {topic}')
print(f' {summary}')
if voices:
print(f' Voices: {voices}')
print()
else:
text = data.get('text', '')
if text:
print(text)
ideas = data.get('ideas', [])
if ideas:
print('Telegram post ideas:')
for i, idea in enumerate(ideas, 1):
print(f' {i}. {idea}')
" "$_parsed" | print_head "$LIMIT"
echo "" >&2
echo "Artifact: $_artifact" >&2