diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index b83df9fe4..dd343c70f 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -206,6 +206,7 @@ export const dict: Record = { "tui.skill.frontend-design.description": "Guidance for distinctive, intentional visual UI design", "tui.skill.loop.description": "Schedule a prompt to run on a recurring interval", "tui.skill.html-to-video-pipeline.description": "Short-video magic — make short videos with HTML", + "tui.skill.arxiv.description": "Search, cite, download, and track arXiv papers", // Language switching "tui.command.language.switch.title": "Switch language", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index 3e12ae93c..510e45a53 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -282,6 +282,7 @@ export const dict = { "tui.skill.frontend-design.description": "Guía para un diseño visual de UI distintivo e intencional", "tui.skill.loop.description": "Programar un prompt para ejecutarse en un intervalo recurrente", "tui.skill.html-to-video-pipeline.description": "El arma definitiva para vídeos cortos — crea vídeos cortos con HTML", + "tui.skill.arxiv.description": "Busca, cita, descarga y sigue artículos de arXiv", // Language switching "tui.command.language.switch.title": "Cambiar idioma", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index 62a6d48e5..ef3558282 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -270,6 +270,7 @@ export const dict = { "tui.skill.frontend-design.description": "Conseils pour un design d'interface visuel distinctif et intentionnel", "tui.skill.loop.description": "Planifier l'exécution récurrente d'un prompt", "tui.skill.html-to-video-pipeline.description": "L'arme ultime pour vidéos courtes — créez des vidéos courtes avec du HTML", + "tui.skill.arxiv.description": "Rechercher, citer, télécharger et suivre des articles arXiv", // Language switching "tui.command.language.switch.title": "Changer de langue", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index e10750925..01fea4a22 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -219,6 +219,7 @@ export const dict = { "tui.skill.frontend-design.description": "個性的で意図的な UI ビジュアルデザインのガイド", "tui.skill.loop.description": "プロンプトを一定間隔で繰り返し実行するようスケジュール", "tui.skill.html-to-video-pipeline.description": "ショート動画の神ツール - HTML でショート動画を制作", + "tui.skill.arxiv.description": "arXiv 論文の検索・引用・ダウンロード・追跡", // Language switching "tui.command.language.switch.title": "言語を切り替え", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 7b3db7184..110a77da7 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -285,6 +285,7 @@ export const dict = { "tui.skill.frontend-design.description": "Руководство по выразительному, осмысленному визуальному дизайну UI", "tui.skill.loop.description": "Запланировать запуск промпта с периодичностью", "tui.skill.html-to-video-pipeline.description": "Магический инструмент для коротких видео — создавайте короткие видео с помощью HTML", + "tui.skill.arxiv.description": "Поиск, цитирование, загрузка и отслеживание статей arXiv", // Language switching "tui.command.language.switch.title": "Сменить язык", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/skill.ts b/packages/opencode/src/cli/cmd/tui/i18n/skill.ts index 4e4c5c724..7d87c50f8 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/skill.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/skill.ts @@ -8,6 +8,7 @@ const BUILTIN = new Set([ "frontend-design", "loop", "html-to-video-pipeline", + "arxiv", ]) export function skillDescription( diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index f106d0844..c1790e4b9 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -195,6 +195,7 @@ export const dict = { "tui.skill.frontend-design.description": "具备鲜明主张的前端视觉设计指南", "tui.skill.loop.description": "按固定周期循环运行提示词", "tui.skill.html-to-video-pipeline.description": "短视频神器 - 利用 HTML 制作短视频", + "tui.skill.arxiv.description": "搜索、引用、下载与追踪 arXiv 论文", // Language switching "tui.command.language.switch.title": "切换语言", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 9551813b2..47459139d 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -195,6 +195,7 @@ export const dict = { "tui.skill.frontend-design.description": "具備鮮明主張的前端視覺設計指引", "tui.skill.loop.description": "依固定週期循環執行提示詞", "tui.skill.html-to-video-pipeline.description": "短影片神器 - 利用 HTML 製作短影片", + "tui.skill.arxiv.description": "搜尋、引用、下載與追蹤 arXiv 論文", // Language switching "tui.command.language.switch.title": "切換語言", diff --git a/packages/opencode/src/skill/builtin/.bundle/arxiv/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/arxiv/SKILL.md new file mode 100644 index 000000000..efb5cd77a --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/arxiv/SKILL.md @@ -0,0 +1,112 @@ +--- +name: arxiv +description: "Use this skill whenever the user wants to find, read, cite, track, download, or analyze academic papers on arXiv. That includes: searching papers by topic, author, category, or arXiv ID; fetching abstracts or full metadata; generating BibTeX citations; downloading PDFs; listing the latest submissions in a field (e.g. cs.AI daily digest); checking a paper's citation impact; finding who cites a paper, what it references, or related-paper recommendations. Trigger on mentions of 'arXiv', an arXiv ID (e.g. 2601.02780 or hep-th/0601001), an arxiv.org URL, 'paper search', 'literature review', 'find papers about X', 'cite this paper', or 'what's new in cs.LG'." +version: 2.0.0 +license: MIT +platforms: [linux, macos, windows] +--- + +# arXiv Research + +Search, read, cite, and analyze academic papers using the free arXiv API and Semantic Scholar API. No API keys, no dependencies — the bundled script uses only the Python stdlib. + +## Quick Start: the `arxiv.py` script + +Prefer `scripts/arxiv.py` over raw `curl` — it handles Atom XML parsing, versioned IDs, withdrawn-paper detection, and produces clean readable (or `--json`) output. + +| Goal | Command | +|------|---------| +| Search papers | `python scripts/arxiv.py search "GRPO reinforcement learning" --max 10 --sort date` | +| Filtered search | `python scripts/arxiv.py search "attention" --author vaswani --category cs.CL` | +| Full metadata + abstract | `python scripts/arxiv.py get 2601.02780,1706.03762` | +| BibTeX citation | `python scripts/arxiv.py bibtex 2601.02780` | +| Download PDF | `python scripts/arxiv.py download 2601.02780 --dest ./papers` | +| Latest in a category | `python scripts/arxiv.py new cs.CL --max 10` | +| Who cites this paper | `python scripts/arxiv.py cites 2601.02780 --max 20` | +| What this paper cites | `python scripts/arxiv.py refs 2601.02780` | +| Related-paper recommendations | `python scripts/arxiv.py similar 2601.02780` | +| Machine-readable output | append `--json` to any command | + +Common flags: `--max N` (result count), `--sort relevance|date|updated`, `--start N` (pagination offset, search only), `--json`. + +## Reading Paper Content + +After finding a paper, read it with the `webfetch` tool: + +- Abstract page (fast, metadata + abstract): `https://arxiv.org/abs/2601.02780` +- Full paper as HTML (best for reading, when available): `https://arxiv.org/html/2601.02780` +- PDF: `https://arxiv.org/pdf/2601.02780` + +If HTML is unavailable and the PDF must be processed locally, `download` it first, then use a PDF-processing skill. + +## Recommended Research Workflows + +**Literature review on a topic** +1. `search "topic" --sort date --max 15` — recent work +2. `search "topic" --max 15` — seminal work (relevance-sorted) +3. Cross-check impact: `python scripts/arxiv.py get ID` then `cites ID --max 5` for citation counts +4. Read the top candidates via `webfetch` on the abs/html URLs +5. `bibtex ID1,ID2,...` for the papers you keep + +**Deep-dive a single paper** +1. `get ID` — full abstract, versions, journal ref, DOI +2. `refs ID` — what it builds on +3. `cites ID` — follow-up work (sorted by citation count) +4. `similar ID` — related papers you might have missed +5. `webfetch` the HTML/PDF for full text + +**Stay current in a field** +- `new cs.AI --max 20` — latest submissions in a category +- Category taxonomy: https://arxiv.org/category_taxonomy — common ones: `cs.AI`, `cs.CL` (NLP), `cs.CV`, `cs.LG`, `cs.CR`, `stat.ML`, `math.OC` + +## Raw API Reference (when the script isn't enough) + +The script covers most needs; use the raw APIs for advanced queries. + +### arXiv API (Atom XML) + +```bash +curl -s "https://export.arxiv.org/api/query?search_query=all:transformer&max_results=5" +``` + +Field prefixes: `all:` (everything), `ti:` (title), `au:` (author), `abs:` (abstract), `cat:` (category), `co:` (comment, e.g. `co:accepted+NeurIPS`). + +Boolean syntax (URL-encode spaces as `+`): + +``` +all:GPT+OR+all:BERT # OR +all:language+model+ANDNOT+all:vision # AND NOT +ti:"chain+of+thought" # exact phrase +au:hinton+AND+cat:cs.LG # combined +``` + +Parameters: `sortBy` (`relevance`|`lastUpdatedDate`|`submittedDate`), `sortOrder`, `start`, `max_results`, `id_list` (comma-separated IDs). + +### Semantic Scholar API (JSON) + +arXiv has no citation data — Semantic Scholar fills that gap (free, ~1 req/sec unauthenticated). + +```bash +# Paper details with citation counts +curl -s "https://api.semanticscholar.org/graph/v1/paper/arXiv:2601.02780?fields=title,citationCount,influentialCitationCount,tldr" + +# Author profile +curl -s "https://api.semanticscholar.org/graph/v1/author/search?query=Yann+LeCun&fields=name,hIndex,citationCount,paperCount" + +# Keyword search returning JSON (alternative to arXiv search) +curl -s "https://api.semanticscholar.org/graph/v1/paper/search?query=GRPO&limit=5&fields=title,year,citationCount,externalIds" +``` + +Useful fields: `title`, `authors`, `year`, `abstract`, `tldr` (AI summary), `citationCount`, `influentialCitationCount`, `isOpenAccess`, `openAccessPdf`, `fieldsOfStudy`, `publicationVenue`, `externalIds` (arXiv ID, DOI). + +## Important Details + +**Rate limits** — arXiv: ~1 request / 3 seconds; Semantic Scholar: ~1 request / second. Space out consecutive calls; the script exits with a clear message on HTTP 429. + +**ID formats** — new style `2402.03300`, old style `hep-th/0601001`. Both work everywhere. + +**Versioning** — `arxiv.org/abs/2601.02780` resolves to the latest version; `...v2` is a specific immutable version. `bibtex` and `download` preserve the version suffix so citations match the content you actually read (later versions can change substantially). + +**Withdrawn papers** — the script flags entries whose abstract indicates withdrawal/retraction with `[WITHDRAWN]`. Don't cite these without noting the status. + +**Listings caveat** — `new CATEGORY` sorts by submission date via the search API; the official "new today" listing at `https://arxiv.org/list/cs.AI/new` may group slightly differently (cross-lists, replacements). diff --git a/packages/opencode/src/skill/builtin/.bundle/arxiv/scripts/arxiv.py b/packages/opencode/src/skill/builtin/.bundle/arxiv/scripts/arxiv.py new file mode 100644 index 000000000..cf20387b0 --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/arxiv/scripts/arxiv.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""arXiv research CLI — search, read, cite, download, and analyze papers. + +Stdlib only. No API keys required. + +Commands: + search QUERY Search arXiv (combine with --author/--category) + get ID[,ID...] Full metadata + abstract for specific papers + bibtex ID[,ID...] BibTeX entries for papers + download ID Download the PDF (--dest DIR, default cwd) + new CATEGORY Latest submissions in a category (e.g. cs.AI) + cites ID Papers citing this one (Semantic Scholar) + refs ID Papers this one references (Semantic Scholar) + similar ID Recommended related papers (Semantic Scholar) + +Examples: + python arxiv.py search "MiMo Technical Report" --max 10 --sort date + python arxiv.py search "attention" --author vaswani --category cs.CL + python arxiv.py get 2601.02780,1706.03762 + python arxiv.py bibtex 2601.02780 + python arxiv.py download 2601.02780 --dest ./papers + python arxiv.py new cs.CL --max 10 + python arxiv.py cites 2601.02780 --max 20 + python arxiv.py similar 2601.02780 + python arxiv.py search "diffusion models" --json + +Flags: + --max N Max results (default 5) + --start N Result offset for pagination (search only) + --sort MODE relevance | date | updated (search only) + --author NAME Filter by author (search only) + --category CAT Filter by category (search only) + --dest DIR Download directory (download only) + --json Emit JSON instead of readable text +""" +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +ARXIV_API = "https://export.arxiv.org/api/query" +S2_API = "https://api.semanticscholar.org" +NS = { + "a": "http://www.w3.org/2005/Atom", + "arxiv": "http://arxiv.org/schemas/atom", + "os": "http://a9.com/-/spec/opensearch/1.1/", +} +UA = "MiMoCode-arxiv-skill/2.0" + + +def http_get(url, timeout=30): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + except urllib.error.HTTPError as e: + if e.code == 429: + sys.exit(f"Rate limited by {urllib.parse.urlsplit(url).netloc} — wait a few seconds and retry.") + sys.exit(f"HTTP {e.code} fetching {url}") + except urllib.error.URLError as e: + sys.exit(f"Network error: {e.reason}") + + +def text_of(entry, tag): + node = entry.find(tag, NS) + return node.text.strip() if node is not None and node.text else "" + + +def parse_entry(entry): + raw_id = text_of(entry, "a:id") + full_id = raw_id.split("/abs/")[-1] if "/abs/" in raw_id else raw_id + base_id = re.sub(r"v\d+$", "", full_id) + summary = re.sub(r"\s+", " ", text_of(entry, "a:summary")) + primary = entry.find("arxiv:primary_category", NS) + doi = text_of(entry, "arxiv:doi") + comment = text_of(entry, "arxiv:comment") + journal = text_of(entry, "arxiv:journal_ref") + return { + "id": base_id, + "version": full_id[len(base_id):], + "title": re.sub(r"\s+", " ", text_of(entry, "a:title")), + "authors": [a.find("a:name", NS).text for a in entry.findall("a:author", NS)], + "published": text_of(entry, "a:published")[:10], + "updated": text_of(entry, "a:updated")[:10], + "abstract": summary, + "categories": [c.get("term") for c in entry.findall("a:category", NS)], + "primary_category": primary.get("term") if primary is not None else "", + "doi": doi, + "comment": comment, + "journal_ref": journal, + "abs_url": f"https://arxiv.org/abs/{base_id}", + "pdf_url": f"https://arxiv.org/pdf/{base_id}", + "html_url": f"https://arxiv.org/html/{base_id}", + "withdrawn": "withdrawn" in summary.lower()[:200] or "retracted" in summary.lower()[:200], + } + + +def fetch_entries(params): + url = ARXIV_API + "?" + urllib.parse.urlencode(params) + root = ET.fromstring(http_get(url)) + total = root.find("os:totalResults", NS) + entries = [parse_entry(e) for e in root.findall("a:entry", NS)] + return int(total.text) if total is not None else len(entries), entries + + +def print_paper(p, i=None, full=False): + prefix = f"{i}. " if i is not None else "" + flag = " [WITHDRAWN]" if p["withdrawn"] else "" + print(f"{prefix}{p['title']}{flag}") + print(f" ID: {p['id']}{p['version']} | Published: {p['published']} | Updated: {p['updated']}") + print(f" Authors: {', '.join(p['authors'])}") + print(f" Categories: {', '.join(p['categories'])}") + if p["journal_ref"]: + print(f" Journal: {p['journal_ref']}") + if p["comment"]: + print(f" Comment: {p['comment']}") + if p["doi"]: + print(f" DOI: {p['doi']}") + abstract = p["abstract"] if full else p["abstract"][:300] + ("..." if len(p["abstract"]) > 300 else "") + print(f" Abstract: {abstract}") + print(f" Links: {p['abs_url']} | {p['pdf_url']}") + print() + + +def emit(papers, args, total=None, full=False): + if args.json: + print(json.dumps({"total": total, "results": papers} if total is not None else papers, + ensure_ascii=False, indent=2)) + return + if not papers: + print("No results found.") + return + if total is not None: + print(f"Found {total} results (showing {len(papers)})\n") + for i, p in enumerate(papers, 1): + print_paper(p, i, full=full) + + +def cmd_search(args): + parts = [] + if args.query: + parts.append(f"all:{args.query}") + if args.author: + parts.append(f'au:"{args.author}"') + if args.category: + parts.append(f"cat:{args.category}") + if not parts: + sys.exit("Provide a query, --author, or --category.") + sort_map = {"relevance": "relevance", "date": "submittedDate", "updated": "lastUpdatedDate"} + total, papers = fetch_entries({ + "search_query": " AND ".join(parts), + "start": args.start, + "max_results": args.max, + "sortBy": sort_map[args.sort], + "sortOrder": "descending", + }) + emit(papers, args, total=total) + + +def cmd_get(args): + _, papers = fetch_entries({"id_list": args.ids, "max_results": 100}) + emit(papers, args, full=True) + + +def bibtex_key(p): + last = p["authors"][0].split()[-1].lower() if p["authors"] else "unknown" + return re.sub(r"[^a-z0-9]", "", last) + p["published"][:4] + p["id"].replace(".", "").replace("/", "") + + +def cmd_bibtex(args): + _, papers = fetch_entries({"id_list": args.ids, "max_results": 100}) + if not papers: + sys.exit("Paper not found.") + for p in papers: + eprint = p["id"] + p["version"] # keep version to prevent citation drift + lines = [ + f"@misc{{{bibtex_key(p)},", + f" title = {{{p['title']}}},", + f" author = {{{' and '.join(p['authors'])}}},", + f" year = {{{p['published'][:4]}}},", + f" eprint = {{{eprint}}},", + " archivePrefix = {arXiv},", + f" primaryClass = {{{p['primary_category']}}},", + f" url = {{https://arxiv.org/abs/{eprint}}},", + ] + if p["doi"]: + lines.append(f" doi = {{{p['doi']}}},") + lines.append("}") + print("\n".join(lines)) + print() + + +def cmd_download(args): + paper_id = args.ids.split(",")[0] + _, papers = fetch_entries({"id_list": paper_id}) + if not papers: + sys.exit(f"Paper {paper_id} not found.") + p = papers[0] + if p["withdrawn"]: + print(f"Warning: {p['id']} appears to be withdrawn.", file=sys.stderr) + os.makedirs(args.dest, exist_ok=True) + dest = os.path.join(args.dest, f"{p['id'].replace('/', '_')}{p['version']}.pdf") + data = http_get(f"https://arxiv.org/pdf/{p['id']}{p['version']}", timeout=120) + if not data.startswith(b"%PDF"): + sys.exit("Downloaded content is not a PDF (paper may lack a PDF version).") + with open(dest, "wb") as f: + f.write(data) + print(f"Saved: {dest} ({len(data) // 1024} KB)") + print(f"Title: {p['title']}") + + +def cmd_new(args): + total, papers = fetch_entries({ + "search_query": f"cat:{args.category}", + "max_results": args.max, + "sortBy": "submittedDate", + "sortOrder": "descending", + }) + emit(papers, args, total=total) + + +S2_FIELDS = "title,authors,year,citationCount,externalIds,abstract" + + +def s2_paper_row(p): + ext = p.get("externalIds") or {} + return { + "title": p.get("title"), + "authors": [a["name"] for a in p.get("authors") or []], + "year": p.get("year"), + "citations": p.get("citationCount"), + "arxiv_id": ext.get("ArXiv"), + "doi": ext.get("DOI"), + } + + +def print_s2(rows, args, header): + if args.json: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return + if not rows: + print("No results.") + return + print(f"{header}\n") + for i, r in enumerate(rows, 1): + arxiv = f" | arXiv:{r['arxiv_id']}" if r["arxiv_id"] else "" + print(f"{i}. {r['title']} ({r['year']})") + print(f" Authors: {', '.join(r['authors'][:6])}{' ...' if len(r['authors']) > 6 else ''}") + print(f" Citations: {r['citations']}{arxiv}") + print() + + +def cmd_s2_links(args, direction): + paper_id = args.ids.split(",")[0] + url = f"{S2_API}/graph/v1/paper/arXiv:{paper_id}/{direction}?fields={S2_FIELDS}&limit={args.max}" + data = json.loads(http_get(url)) + key = "citingPaper" if direction == "citations" else "citedPaper" + rows = [s2_paper_row(item[key]) for item in data.get("data", [])] + rows.sort(key=lambda r: r["citations"] or 0, reverse=True) + label = "Papers citing" if direction == "citations" else "References of" + print_s2(rows, args, f"{label} arXiv:{paper_id} (sorted by citations)") + + +def cmd_similar(args): + paper_id = args.ids.split(",")[0] + url = f"{S2_API}/recommendations/v1/papers/forpaper/arXiv:{paper_id}?fields={S2_FIELDS}&limit={args.max}" + data = json.loads(http_get(url)) + rows = [s2_paper_row(p) for p in data.get("recommendedPapers", [])] + print_s2(rows, args, f"Papers similar to arXiv:{paper_id}") + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("command", nargs="?") + parser.add_argument("query", nargs="*") + parser.add_argument("--max", type=int, default=5) + parser.add_argument("--start", type=int, default=0) + parser.add_argument("--sort", choices=["relevance", "date", "updated"], default="relevance") + parser.add_argument("--author") + parser.add_argument("--category") + parser.add_argument("--dest", default=".") + parser.add_argument("--json", action="store_true") + parser.add_argument("-h", "--help", action="store_true") + args = parser.parse_args() + + if args.help or not args.command: + print(__doc__) + sys.exit(0) + + args.ids = ",".join(args.query) + args.query = " ".join(args.query) + + commands = { + "search": cmd_search, + "get": cmd_get, + "bibtex": cmd_bibtex, + "download": cmd_download, + "new": lambda a: cmd_new(argparse.Namespace(**{**vars(a), "category": a.query or a.category})), + "cites": lambda a: cmd_s2_links(a, "citations"), + "refs": lambda a: cmd_s2_links(a, "references"), + "similar": cmd_similar, + } + handler = commands.get(args.command) + if handler is None: + # bare query fallback: treat first arg as part of the search + args.query = (args.command + " " + args.query).strip() + handler = cmd_search + if args.command == "new" and not (args.query or args.category): + sys.exit("Provide a category, e.g.: python arxiv.py new cs.AI") + handler(args) + + +if __name__ == "__main__": + main()