research-assistant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README-ZH.md +115 -0
  3. package/README.md +124 -0
  4. package/npm/bin/research-assistant.js +70 -0
  5. package/npm/scripts/postinstall.js +112 -0
  6. package/package.json +38 -0
  7. package/pyproject.toml +84 -0
  8. package/src/research_assistant/__init__.py +7 -0
  9. package/src/research_assistant/__main__.py +6 -0
  10. package/src/research_assistant/assets/researcher_persona.md +94 -0
  11. package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
  12. package/src/research_assistant/cli.py +155 -0
  13. package/src/research_assistant/commands/__init__.py +7 -0
  14. package/src/research_assistant/commands/ask.py +54 -0
  15. package/src/research_assistant/commands/doctor.py +201 -0
  16. package/src/research_assistant/commands/fetch.py +99 -0
  17. package/src/research_assistant/commands/locate.py +71 -0
  18. package/src/research_assistant/commands/search.py +161 -0
  19. package/src/research_assistant/commands/setup.py +249 -0
  20. package/src/research_assistant/commands/skills.py +60 -0
  21. package/src/research_assistant/config.py +258 -0
  22. package/src/research_assistant/errors.py +106 -0
  23. package/src/research_assistant/fetch/__init__.py +20 -0
  24. package/src/research_assistant/fetch/browser.py +613 -0
  25. package/src/research_assistant/fetch/cfbypass.py +318 -0
  26. package/src/research_assistant/fetch/html2md.py +44 -0
  27. package/src/research_assistant/fetch/normal.py +69 -0
  28. package/src/research_assistant/fetch/search_engine.py +324 -0
  29. package/src/research_assistant/fetch/snapshot.py +50 -0
  30. package/src/research_assistant/http.py +118 -0
  31. package/src/research_assistant/installer.py +270 -0
  32. package/src/research_assistant/locate/__init__.py +5 -0
  33. package/src/research_assistant/locate/engine.py +289 -0
  34. package/src/research_assistant/loginstate/__init__.py +10 -0
  35. package/src/research_assistant/loginstate/daemon.py +145 -0
  36. package/src/research_assistant/loginstate/daemon_cli.py +21 -0
  37. package/src/research_assistant/loginstate/daemonctl.py +116 -0
  38. package/src/research_assistant/loginstate/extension/background.js +167 -0
  39. package/src/research_assistant/loginstate/extension/manifest.json +15 -0
  40. package/src/research_assistant/loginstate/extension/popup.html +31 -0
  41. package/src/research_assistant/loginstate/extension/popup.js +33 -0
  42. package/src/research_assistant/output.py +140 -0
  43. package/src/research_assistant/providers/__init__.py +10 -0
  44. package/src/research_assistant/providers/base.py +93 -0
  45. package/src/research_assistant/providers/browser.py +91 -0
  46. package/src/research_assistant/providers/context7.py +171 -0
  47. package/src/research_assistant/providers/exa.py +353 -0
  48. package/src/research_assistant/providers/firecrawl.py +644 -0
  49. package/src/research_assistant/providers/openai_compat.py +293 -0
  50. package/src/research_assistant/providers/registry.py +62 -0
  51. package/src/research_assistant/providers/tavily.py +346 -0
  52. package/src/research_assistant/proxy.py +58 -0
  53. package/src/research_assistant/targets.py +66 -0
@@ -0,0 +1,248 @@
1
+ ---
2
+ name: research-assistant
3
+ description: Search the open web, fetch official library docs, pull page content past logins or anti-bot challenges, and locate passages in long documents. This skill is a set of concurrent CLI tools that gather candidate sources and pages; it does not synthesize answers. It covers web search for sources, library/framework/SDK/CLI/cloud docs lookup, fetching pages behind Cloudflare or a login, and locating where a topic sits in a long document.
4
+ ---
5
+
6
+ # research-assistant
7
+
8
+ `research-assistant` is a set of concurrent CLI atomic tools for gathering external
9
+ information: web search providers, official library docs, cross-tool page fetch, and
10
+ anchor-based locate for long local documents. It is **middleware**. It collects sources and
11
+ pages; you read them and synthesize your own answer.
12
+
13
+ ## Output boundary
14
+
15
+ These tools gather sources and pages. They do not synthesize answers, do not rank which source
16
+ to trust when sources disagree, and do not decide correctness. You read what they return and
17
+ draw your own conclusions.
18
+
19
+ ## How to read this skill
20
+
21
+ The Capability map and the Commands list describe what each tool is good at and when to use
22
+ it. Treat those as guidance for picking a tool, not as a fixed routing order. The tier numbers
23
+ (Tier 1/2/3) express a preference for the faster, cheaper tool first, not a hard rule: if you
24
+ already know a page needs a browser, go straight to `fetch`. Anything in a command's inline
25
+ comment is a strength hint, not a contract.
26
+
27
+ ## Primary tools: fetch, search, ask
28
+
29
+ Three commands do the heavy lifting and need no configuration to start. Reach for them first.
30
+
31
+ ### `fetch <url>...`: cross-tool page fetch with automatic escalation
32
+
33
+ `fetch` tries the cheap path first and climbs only on failure: the normal APIs (tavily extract,
34
+ firecrawl scrape), then a headed real browser with Cloudflare auto-detect. Login cookies are
35
+ injected through the bundled extension bridge, so logged-in pages work without re-entering
36
+ credentials. It works out of the box: with zero provider config the normal APIs simply fail and
37
+ the browser fallback still returns the page.
38
+
39
+ ### `search "<query>"`: aggregated multi-source search, browser included by default
40
+
41
+ `search` fans the query across the configured search APIs (exa, tavily) and the browser search
42
+ engine, then merges and de-duplicates the candidates. One source failing does not block the
43
+ others. The browser source is always in the default set, so `search` works with zero provider
44
+ config: with nothing configured it still runs the headless browser search engine and returns
45
+ candidates. Pass `--providers exa,tavily` to restrict to the fast APIs only.
46
+
47
+ ### When to reach for browser search
48
+
49
+ Reach for `browser search`, or the browser source inside `search`, when the APIs come back empty
50
+ or shallow. It is slower than the APIs, but it runs against the user's real Edge/Chromium and
51
+ searches the way a person does, so sites that hide behind strong anti-bot become reachable, and
52
+ those sites usually hide because their content is worth protecting. It carries the user's login
53
+ cookies and search history, so the results track their habits and the search engine's
54
+ personalization model. When exa and tavily give you nothing useful on a niche or protected topic,
55
+ fall back to browser search before you give up. It runs headless, so it does not steal focus from
56
+ the user's daily browser.
57
+
58
+ ### `ask "<question>"`: natural-language answer from a web-enabled LLM
59
+
60
+ `ask` sends your question to the configured `openai_compat` LLM (pick a model with built-in web
61
+ search) and returns its markdown answer plus an extracted citations list. Use it for a direct
62
+ answer to a small question. Treat the prose as a lead, not a fact: for anything that matters,
63
+ `fetch` the cited page and read it yourself.
64
+
65
+ ## Capability map (pick by what the task needs; no hardcoded routing)
66
+
67
+ Four independent domains. Each tool has a distinct strength; choose per task, not by a fixed
68
+ order. Tool selection is YOUR decision.
69
+
70
+ ### A. Web search and page fetch, tiered by anti-bot strength
71
+
72
+ When you need the open web, prefer the **lowest tier that can do the job**. API tools are
73
+ fastest because they skip browser rendering, and they cost the least. Escalate only when a
74
+ tier genuinely cannot return the content.
75
+
76
+ | Tier | Tool | Strength | Use when |
77
+ |------|------|----------|----------|
78
+ | 1. API | `exa`, `tavily` | Structured search APIs; clean JSON, no rendering. `tavily extract` returns ready markdown, `tavily map`/`crawl` cover a whole site; `exa` adds `similar`, `contents`, `answer`, and async `research`. Fast and cheap. | Simple or public pages; search with snippets; fast fetch of a known URL; site map or crawl. |
79
+ | 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape` and `search` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `interact`, `agent`, `monitor`, `parse`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
80
+ | 3. Browser | `fetch`, `browser` | Drives the user's real Edge/Chromium, same engine as the daily browser so login cookies stay valid. `fetch` auto-escalates: normal API, then a headed browser with Cloudflare auto-detect (no challenge means solve returns instantly). `browser` is the direct browser platform when you already know you want the browser: `browser fetch` (headed + CF auto-detect, skips the API) and `browser search` (headless search engine: Bing CN/intl, Google). | Everything above failed; or the page is behind Cloudflare, needs JS, or needs login cookies; or you want a browser search engine directly. |
81
+
82
+ Notes:
83
+ - Escalation is **automatic inside `fetch`**: normal API, then a headed browser with CF
84
+ auto-detect (no CF challenge means solve returns instantly). You usually just call `fetch`
85
+ and let it climb.
86
+ - `browser` is the direct browser platform, used when you already know the page needs a
87
+ browser: `browser fetch` skips the API and goes straight to the headed browser with CF
88
+ auto-detect; `browser search` runs a headless search engine (default Bing international,
89
+ since CN Bing filters some sensitive terms).
90
+ - `fetch` and `browser fetch` inject login cookies through the bundled MV3 extension bridge,
91
+ so logged-in pages work without re-entering credentials.
92
+ - The Cloudflare bypass runs on DrissionPage against the user's real browser. If that runtime
93
+ dependency is missing, `fetch` and `browser fetch` report a clear install hint (exit code 5,
94
+ antibot) instead of silently failing.
95
+
96
+ ### B. Official docs, Context7 (`ctx7`), an independent domain
97
+
98
+ `ctx7 library "<name>" "<question>"` resolves a library id. `ctx7 docs /org/repo
99
+ "<question>"` returns up-to-date official docs as markdown. It hits the authoritative source
100
+ directly, so it is fast and accurate.
101
+
102
+ **For any library, framework, SDK, CLI, or cloud-service question, prefer `ctx7` over web
103
+ search.** Do not grep the open web for API syntax, config keys, or version-migration notes
104
+ that Context7 can serve from the source.
105
+
106
+ ### C. Ask a web-enabled LLM (`ask`), natural-language Q&A, LOW trust on the answer
107
+
108
+ `ask "<question>"` sends your natural-language question to the configured `openai_compat`
109
+ LLM (model must have built-in web search). It returns the model's markdown answer with
110
+ inline source links, plus an extracted citations list. Use it for a direct answer to a small
111
+ question; this differs from `search`, which aggregates the exa/tavily search APIs and returns
112
+ only URLs.
113
+
114
+ Why low trust on the answer: the LLM is great for **breadth** and for fuzzy or niche
115
+ questions, but its synthesized prose is unreliable. Expect context pollution, hallucinated
116
+ URLs, and stale facts. Treat the answer as a lead; for any actual fact, `fetch` the cited
117
+ page and read it yourself.
118
+
119
+ ### D. Long local documents, `locate`
120
+
121
+ `locate <md_path> "<query>"` does anchor-based relevance scanning: it chunks the document,
122
+ scores chunks with a small model, then matches code strings back to exact line numbers. Use
123
+ it to **pre-screen which parts of a long page or doc matter** before reading, so you avoid
124
+ blind full reads of 10k-line files.
125
+
126
+ ## Core principle: consume sources, do not inherit synthesis
127
+
128
+ Every search and locate tool returns **candidate sources or locations**, not a conclusion to
129
+ trust verbatim. Read the fetched sources yourself and synthesize your own answer. Never
130
+ fabricate a source you did not fetch.
131
+
132
+ ## Use high concurrency; this is the point of the toolkit
133
+
134
+ These tools are cheap and independent. Fire many in parallel; do not serialize.
135
+
136
+ - **One question, many sources at once.** In a single turn, issue `exa search`, `tavily
137
+ search`, `ctx7 docs`, and `ask` together (parallel tool calls); merge and de-duplicate
138
+ the candidate URLs, then `fetch` the best ones. This is far faster than search, read,
139
+ search, read.
140
+ - **Batch fetch.** `fetch url1 url2 url3` fetches all concurrently (one browser, many pages,
141
+ `--concurrency N`). `firecrawl scrape u1 u2 u3` batches too. Never loop one URL at a time
142
+ when a batch call exists.
143
+ - **Many pages, one locate.** Run `locate` once on the whole document to pin the relevant
144
+ sections, then read only those.
145
+
146
+ Reserve serial work for genuine dependencies (you need URL X's content to formulate the next
147
+ query). Otherwise, fan out.
148
+
149
+ ## Commands (JSON on stdout by default; `--output markdown` for readable markdown)
150
+
151
+ Pick a tool by what the task needs. Each command's comment states its strength and when to
152
+ use it. Async endpoints (crawl, extract, research, agent) block until done or `--poll-timeout`,
153
+ and accept `--async-submit` to return the job id immediately instead of waiting.
154
+
155
+ ### A. Web search and page fetch, escalate only when the lower tier fails
156
+
157
+ ```sh
158
+ # Tier 1: structured search APIs. Clean JSON, fastest, cheapest. Start here for simple or public pages.
159
+ research-assistant exa search "<query>" [--num-results N] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
160
+ research-assistant exa similar <url> [--num-results N] # pages semantically similar to a URL
161
+ research-assistant exa contents <id>... [--text] [--highlights] # fetch body for Exa IDs or URLs
162
+ research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results
163
+ research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research
164
+
165
+ research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
166
+ research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering
167
+ research-assistant tavily map <url> [--max-depth N] [--max-breadth N] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N] # list a site's reachable URLs
168
+ research-assistant tavily crawl <url> [--max-depth N] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N] # recursive crawl, markdown per page
169
+
170
+ # Tier 2: Firecrawl. Residential bandwidth. scrape and search are keyless; the rest need a key.
171
+ research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # batch markdown, keyless
172
+ research-assistant firecrawl search "<query>" [--limit N] [--sources web|news] [--scrape] # keyless; --scrape returns full markdown per result
173
+ research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key
174
+ research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # async recursive crawl
175
+ research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # LLM structured extraction, async
176
+ research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S] [--stop] # live browser session bound to a scrape
177
+ research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # autonomous extraction
178
+ research-assistant firecrawl monitor # list active crawl jobs
179
+ research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N] # parse a local document
180
+
181
+ # Tier 3: real browser. fetch auto-escalates (normal API, then headed browser with CF auto-detect).
182
+ research-assistant fetch <url> [<url>...] [--concurrency N] [--login|--no-login] [--no-browser] [--output PATH]
183
+
184
+ # browser platform: direct browser, no API attempt. fetch = headed + CF auto-detect; search = headless engine.
185
+ research-assistant browser fetch <url>... [--no-login] [--concurrency N] [--write PATH] # skip the API, straight to the headed browser (--write, not global --output)
186
+ research-assistant browser search "<query>" [--engine bing-cn|bing-intl|google] [--limit N] [--max-pages N] # headless search engine (default bing-intl)
187
+
188
+ # Aggregate the search sources in one call (default: configured exa,tavily + browser; browser is
189
+ # always in the default set and works with zero config). Keywords, not an LLM.
190
+ research-assistant search "<query>" [--providers exa,tavily,firecrawl,browser] [--limit N]
191
+ ```
192
+
193
+ ### B. Official docs, Context7 (authoritative; prefer over web search for library/SDK/CLI/cloud)
194
+
195
+ ```sh
196
+ research-assistant ctx7 library "<name>" "<question>" # resolve a library id
197
+ research-assistant ctx7 docs /org/repo "<question>" # up-to-date official docs
198
+ ```
199
+
200
+ ### C. Ask a web-enabled LLM in natural language (`ask`)
201
+
202
+ `ask` sends your question to the configured `openai_compat` provider (config `[providers.openai_compat]`: base_url, api_key, model). Pick a model with built-in web search; the CLI sends a plain chat request and the model searches on its own, returning a markdown answer with inline source links. The command returns that markdown plus an extracted citations list. Use it for a direct answer to a small question; to gather URLs from the search APIs instead, use `search`.
203
+
204
+ ```sh
205
+ research-assistant ask "<natural-language question>" [--system TEXT]
206
+ ```
207
+
208
+ ### D. Long-doc locating, pre-screen which parts of a 10k-line file matter before reading
209
+
210
+ ```sh
211
+ research-assistant locate <md_path> "<query>" [--top N] [--scope lines|paragraph] [--context N] [--concurrency N]
212
+ ```
213
+
214
+ ### Setup and management
215
+
216
+ ```sh
217
+ research-assistant setup [--non-interactive | --config-inline TOML] [--provider TYPE[,k=v,...]] ... [--proxy URL] [--browser-channel msedge|chrome] [--daemon-port N] [--install-skills claude,codex,...] # configure providers/proxy/browser; --install-skills writes the skill+agent files
218
+ research-assistant doctor [--show-config] [--target <name>] # connectivity diagnostics; --show-config prints config (masked), --target checks one provider or builtin
219
+ research-assistant skills status [--targets claude,codex,...] [--skills-root PATH] # managed skill/agent freshness
220
+ research-assistant skills update [--targets claude,codex,...] [--skills-root PATH] # refresh managed files
221
+ ```
222
+
223
+ ### Global flags (allowed anywhere on the command line)
224
+
225
+ ```sh
226
+ research-assistant [--config PATH] [--output json|markdown] [--proxy URL|none] [--verbose] [--version] <command> ...
227
+ ```
228
+
229
+ Every command also takes `-h`/`--help`: `research-assistant --help` lists all commands; `research-assistant <command> [--subcommand] --help` shows that command's arguments.
230
+
231
+ ## Research workflow (you decide the steps)
232
+
233
+ 1. Fan out search and docs (`exa` + `tavily` + `ctx7` + `ask`) to get candidate sources.
234
+ 2. `fetch` the most promising URLs in one batch to get clean local markdown.
235
+ 3. For long pages, `locate` to pin relevant passages before reading.
236
+ 4. Synthesize YOUR answer from what you actually fetched; cite URLs.
237
+
238
+ For large, multi-source investigations, delegate to the `researcher` sub-agent. It runs this
239
+ workflow in an isolated context, writes a full report to `tmp-doc/`, and returns only a
240
+ summary, keeping the main context clean.
241
+
242
+ ## Output and errors
243
+
244
+ Output defaults to JSON on stdout (parseable). Pass `--output markdown` for human or AI
245
+ readable markdown. Errors always surface as
246
+ `{"error":{"code","message","provider?","details?"}}` on stdout, plus a one-line
247
+ `error: ...` on stderr, with exit codes:
248
+ `0` ok, `1` internal, `2` args, `3` config, `4` network, `5` antibot.
@@ -0,0 +1,155 @@
1
+ """CLI 入口:全局 flag 预解析 + 动态命令路由 + 统一分发/错误处理。
2
+
3
+ 命令树(api-contract.md §2):
4
+ research-assistant [全局flags] <tool|cmd> <subcommand> [args] [flags]
5
+
6
+ provider 命令(从 registry 动态生成,ADR-0011):
7
+ ctx7/exa/tavily/firecrawl + openai_compat 的各自子命令
8
+ research-assistant 增值命令:fetch / locate / search
9
+ 管理命令:setup / skills / doctor
10
+
11
+ 全局 flags 可出现在任意位置(预解析抽离):
12
+ --config <path> --output json|markdown --proxy <url> --verbose --version
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import sys
20
+ from typing import Any, Sequence
21
+
22
+ from . import __version__
23
+ from .config import load as load_config
24
+ from .errors import ArgsError, ResearchAssistantError
25
+ from .output import emit, emit_error, emit_json
26
+ from .providers import all_provider_classes
27
+ from . import proxy as proxy_mod
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 命令分发 handler 契约:async(args_ns, config) -> dict
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _wrap_provider_handler(bound_handler: Any) -> Any:
36
+ """把 provider 的 bound handler(self 已绑定) 适配成 (args_ns, config) 契约。"""
37
+
38
+ async def runner(args_ns: argparse.Namespace, config: Any) -> Any:
39
+ return await bound_handler(args_ns)
40
+
41
+ return runner
42
+
43
+
44
+ def _build_parser(config: Any) -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="research-assistant",
47
+ description="搜索调研子 Agent + 可并发的搜索/爬取/定位 CLI 原子工具。",
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ )
50
+ subs = parser.add_subparsers(dest="command", required=True, metavar="<command>")
51
+
52
+ # ---- provider 命令(动态,从 registry 生成)----
53
+ for ptype, pclass in sorted(all_provider_classes().items()):
54
+ provider = pclass(config)
55
+ cap_list = provider.capabilities()
56
+ if not cap_list:
57
+ continue
58
+ names = [provider.command] + list(provider.aliases or [])
59
+ names = [n for n in names if n]
60
+ if not names:
61
+ continue
62
+ p_parser = subs.add_parser(
63
+ names[0],
64
+ aliases=names[1:],
65
+ help=pclass.help,
66
+ description=pclass.help,
67
+ )
68
+ p_sub = p_parser.add_subparsers(dest="subcommand", required=True, metavar="<subcommand>")
69
+ for cap in cap_list:
70
+ c_parser = p_sub.add_parser(cap.name, help=cap.help, description=cap.help)
71
+ for arg in cap.args:
72
+ arg.add_to(c_parser)
73
+ c_parser.set_defaults(_handler=_wrap_provider_handler(cap.handler))
74
+
75
+ # ---- 原生命令 ----
76
+ from .commands import ask as ask_cmd
77
+ from .commands import fetch as fetch_cmd
78
+ from .commands import locate as locate_cmd
79
+ from .commands import search as search_cmd
80
+ from .commands import setup as setup_cmd
81
+ from .commands import skills as skills_cmd
82
+ from .commands import doctor as doctor_cmd
83
+
84
+ for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, setup_cmd, skills_cmd, doctor_cmd):
85
+ cmd.register(subs)
86
+
87
+ return parser
88
+
89
+
90
+ def _parse_globals(argv: Sequence[str]) -> tuple[argparse.Namespace, list[str]]:
91
+ """预解析全局 flags(允许出现在命令任意位置),返回 (globals_ns, remaining_argv)。"""
92
+ pre = argparse.ArgumentParser(add_help=False)
93
+ pre.add_argument("--config", default=None, dest="config_path")
94
+ pre.add_argument("--output", default="json", choices=["json", "markdown"])
95
+ pre.add_argument("--proxy", default=None, help="Proxy URL override (横切); 'none' = force direct, no proxy.")
96
+ pre.add_argument("--verbose", action="store_true")
97
+ pre.add_argument("--version", action="store_true")
98
+ ns, remaining = pre.parse_known_args(list(argv))
99
+ return ns, remaining
100
+
101
+
102
+ def main(argv: Sequence[str] | None = None) -> int:
103
+ if argv is None:
104
+ argv = sys.argv[1:]
105
+ g_ns, remaining = _parse_globals(argv)
106
+
107
+ if g_ns.version:
108
+ print(f"research-assistant {__version__}")
109
+ return 0
110
+
111
+ # 代理横切覆盖(CLI --proxy 优先级最高,ADR-0007)
112
+ proxy_mod.set_override(g_ns.proxy)
113
+
114
+ # 加载配置(文件不存在=首次使用,返回空 Config)
115
+ try:
116
+ config = load_config(g_ns.config_path)
117
+ except ResearchAssistantError as e:
118
+ emit_json(e.to_json())
119
+ emit_error(e.message)
120
+ return e.exit_code()
121
+
122
+ # 构建并解析子命令树
123
+ parser = _build_parser(config)
124
+ try:
125
+ args_ns = parser.parse_args(remaining)
126
+ except SystemExit as e: # argparse 解析失败/--help 已退出
127
+ code = e.code if isinstance(e.code, int) else 2
128
+ return code
129
+
130
+ handler = getattr(args_ns, "_handler", None)
131
+ if handler is None: # 只有顶层命令没有 sub(理论上 required=True 已挡)
132
+ parser.print_help(sys.stderr)
133
+ return 2
134
+
135
+ try:
136
+ result = asyncio.run(handler(args_ns, config))
137
+ except ResearchAssistantError as e:
138
+ emit_json(e.to_json())
139
+ emit_error(e.message)
140
+ return e.exit_code()
141
+ except KeyboardInterrupt:
142
+ emit_error("已中断")
143
+ return 1
144
+ except Exception as e: # 兜底:未预期异常 → INTERNAL
145
+ err = ResearchAssistantError(f"内部错误: {e}", details={"type": type(e).__name__})
146
+ emit_json(err.to_json())
147
+ emit_error(err.message)
148
+ return err.exit_code()
149
+
150
+ emit(result, g_ns.output)
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ """commands 包:research-assistant 原生命令(fetch/locate/search/setup/skills/doctor)。
2
+
3
+ 每个模块暴露:
4
+ NAME / ALIASES / HELP
5
+ register(subparsers) —— 向 CLI 注册子命令与参数,set_defaults(_handler=run)
6
+ async run(args_ns, config) -> dict
7
+ """
@@ -0,0 +1,54 @@
1
+ """ask 命令:自然语言问一个自带 web 搜索的 LLM,拿它的 markdown 回答 + 抽出的信源。
2
+
3
+ research-assistant ask "<natural-language question>"
4
+
5
+ 和 search(聚合 exa/tavily 搜索 API)的区别:ask 走 openai_compat(你配的、model 自带
6
+ web 搜索的 LLM)。你发自然语言意图,LLM 理解后自己搜、自己组织答案;返回 LLM 的
7
+ markdown 回答(引用链接嵌在正文里)+ 抽出的 citations 列表。适合主 agent 遇到小问题、
8
+ 不想自己调一堆工具、直接发自然语言拿结果的轻量场景。
9
+
10
+ model 必须自带 web 搜索(如 grok 类):CLI 只发普通 chat 请求,搜索由 model 自身能力
11
+ 完成;若 model 不会搜,content 里不会有真实信源、citations 为空。
12
+
13
+ 响应:{content(str, markdown), citations[]{url, title?, snippet?}}
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ from typing import Any
20
+
21
+ from ..config import Config
22
+ from ..providers import openai_compat
23
+
24
+ NAME = "ask"
25
+ ALIASES: list[str] = []
26
+ HELP = "Ask a web-enabled LLM in natural language; returns its markdown answer plus sources."
27
+
28
+ _ASK_SYSTEM_PROMPT = (
29
+ "You are a web research assistant. Search the web to answer the user's question. "
30
+ "Answer in clear markdown and cite sources inline as links. "
31
+ "Prefer official documentation and reputable sites."
32
+ )
33
+
34
+
35
+ def register(subparsers: argparse._SubParsersAction) -> None:
36
+ p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
37
+ p.add_argument("query", help="Natural-language question.")
38
+ p.add_argument("--system", help="Override the system prompt.")
39
+ p.set_defaults(_handler=run)
40
+
41
+
42
+ async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
43
+ messages: list[dict[str, str]] = [
44
+ {"role": "system", "content": args.system or _ASK_SYSTEM_PROMPT},
45
+ {"role": "user", "content": args.query},
46
+ ]
47
+ response = await openai_compat.chat_completion(config, messages, temperature=0.0)
48
+ msg = openai_compat.extract_message(response)
49
+ content = openai_compat.strip_think(msg.get("content") or "")
50
+ citations = openai_compat.extract_citations(response, msg)
51
+ out: dict[str, Any] = {"content": content}
52
+ if citations:
53
+ out["citations"] = citations
54
+ return out
@@ -0,0 +1,201 @@
1
+ """doctor 命令:连通性诊断(api-contract §2.3 / 场景 E)。
2
+
3
+ research-assistant doctor [--show-config] [--target <name>]
4
+
5
+ 对每个已配置 provider 做最小请求;locate 模型 ping;浏览器+cookie daemon 探测。
6
+ --show-config 展示配置(密钥遮蔽)。退出码 0=诊断完成(可达性体现在 report)。
7
+ 响应:{checks[]{target, reachable, latency_ms, error?}, config_complete, config?}
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import time
14
+ from typing import Any
15
+
16
+ from .. import providers as providers_pkg
17
+ from ..config import Config, mask_secret
18
+ from ..errors import ResearchAssistantError
19
+ from ..providers import openai_compat
20
+
21
+ NAME = "doctor"
22
+ ALIASES: list[str] = ["diag"]
23
+ HELP = "Run connectivity diagnostics across configured providers."
24
+
25
+
26
+ def register(subparsers: argparse._SubParsersAction) -> None:
27
+ p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
28
+ p.add_argument("--show-config", action="store_true", help="Show config (secrets masked).")
29
+ p.add_argument("--target", help="Only check a single provider/builtin target.")
30
+ p.set_defaults(_handler=run)
31
+
32
+
33
+ async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
34
+ checks: list[dict[str, Any]] = []
35
+ configured_types = {p.type for p in config.providers}
36
+
37
+ targets: list[str] = []
38
+ if args.target:
39
+ targets = [args.target]
40
+ else:
41
+ # 已配置的 provider + 浏览器;firecrawl 免 key 层永远可用,始终检查
42
+ configured = set(configured_types)
43
+ targets = [t for t in ("openai_compat", "locate", "exa", "tavily", "context7") if t in configured]
44
+ targets.append("firecrawl")
45
+ targets.append("browser")
46
+
47
+ for target in targets:
48
+ checks.append(await _check(target, config))
49
+
50
+ config_complete = _config_complete(config)
51
+ result: dict[str, Any] = {"checks": checks, "config_complete": config_complete}
52
+ if args.show_config:
53
+ result["config"] = _masked_config(config)
54
+ return result
55
+
56
+
57
+ def _config_complete(config: Config) -> bool:
58
+ """配置是否足以做完整调研。
59
+
60
+ 搜索源:firecrawl 免 key 层永远可用,故搜索能力始终具备(exa/tavily/context7 是增强)。
61
+ LLM:openai_compat(驱动 search)或 locate(驱动 locate)至少一个就绪才算完整。
62
+ """
63
+ pc = config.provider("openai_compat")
64
+ has_llm = bool(pc and pc.api_key and pc.base_url and pc.model)
65
+ lc = config.provider("locate")
66
+ has_locate = bool(lc and lc.api_key and lc.base_url and lc.model)
67
+ return has_llm or has_locate
68
+
69
+
70
+ async def _check(target: str, config: Config) -> dict[str, Any]:
71
+ start = time.perf_counter()
72
+ try:
73
+ if target == "browser":
74
+ ok, msg = await _check_browser(config)
75
+ elif target == "openai_compat":
76
+ await openai_compat.chat_completion(
77
+ config, [{"role": "user", "content": "ping"}], provider_type="openai_compat"
78
+ )
79
+ ok, msg = True, "ok"
80
+ elif target == "locate":
81
+ await openai_compat.chat_completion(
82
+ config, [{"role": "user", "content": "ping"}], provider_type="locate"
83
+ )
84
+ ok, msg = True, "ok"
85
+ else:
86
+ ok, msg = await _check_provider(target, config)
87
+ except ResearchAssistantError as e:
88
+ ok, msg = False, e.message
89
+ except Exception as e: # noqa: BLE001
90
+ ok, msg = False, f"{type(e).__name__}: {e}"
91
+ latency_ms = round((time.perf_counter() - start) * 1000, 1)
92
+ item: dict[str, Any] = {"target": target, "reachable": ok, "latency_ms": latency_ms}
93
+ if not ok:
94
+ item["error"] = msg
95
+ return item
96
+
97
+
98
+ async def _check_provider(ptype: str, config: Config) -> tuple[bool, str]:
99
+ """用 provider 的最小能力做存活探测(复用插件实例与真实代码路径)。"""
100
+ classes = providers_pkg.all_provider_classes()
101
+ pclass = classes.get(ptype)
102
+ if pclass is None:
103
+ return False, f"provider '{ptype}' 未注册"
104
+ provider = pclass(config)
105
+ caps = provider.capabilities()
106
+ # 选一个轻量能力:search / library / map
107
+ preferred = next((c for c in caps if c.name in ("search", "library", "map")), None)
108
+ if preferred is None:
109
+ return False, f"{ptype}: 无可用探测能力"
110
+ ns = _minimal_namespace(preferred.name)
111
+ await preferred.handler(ns)
112
+ return True, "ok"
113
+
114
+
115
+ def _minimal_namespace(cap_name: str) -> argparse.Namespace:
116
+ """构造各 provider 最小查询所需的 args(带全部默认值,避免 AttributeError)。"""
117
+ base = {
118
+ "query": "test",
119
+ "name": "test",
120
+ "library_id": "/facebook/react",
121
+ "num_results": 1,
122
+ "max_results": 1,
123
+ "limit": 1,
124
+ "type": "auto",
125
+ "depth": "basic",
126
+ "topic": "general",
127
+ "text": False,
128
+ "highlights": False,
129
+ "include_domains": None,
130
+ "exclude_domains": None,
131
+ "category": None,
132
+ "start_date": None,
133
+ "time_range": None,
134
+ "include_answer": None,
135
+ "scrape": False,
136
+ "sources": None,
137
+ "include_subdomains": False,
138
+ "only_main_content": False,
139
+ "wait_for": None,
140
+ "extract_depth": "basic",
141
+ "format": "markdown",
142
+ "tokens": 200,
143
+ "ids": [],
144
+ "url": "https://example.com",
145
+ "urls": ["https://example.com"],
146
+ }
147
+ return argparse.Namespace(**base)
148
+
149
+
150
+ async def _check_browser(config: Config) -> tuple[bool, str]:
151
+ """探测本地浏览器(Playwright channel)与 cookie daemon。"""
152
+ # cookie daemon 状态(HTTP /status)
153
+ daemon_msg = await _check_daemon(config)
154
+ # 浏览器可启动性(仅探测 channel 可执行是否存在,不真启动以省时)
155
+ from ..fetch import browser_probe
156
+
157
+ browser_msg = browser_probe(config.browser.channel)
158
+ ok = "可用" in browser_msg
159
+ msg = f"browser={browser_msg}; daemon={daemon_msg}"
160
+ return ok, msg
161
+
162
+
163
+ async def _check_daemon(config: Config) -> str:
164
+ import aiohttp
165
+
166
+ url = f"http://127.0.0.1:{config.browser.daemon_port}/status"
167
+ try:
168
+ async with aiohttp.ClientSession() as session:
169
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
170
+ if resp.status == 200:
171
+ data = await resp.json()
172
+ return f"在线(扩展已连={data.get('extConnected', False)})"
173
+ return f"HTTP {resp.status}"
174
+ except Exception:
175
+ return "离线(未启动或扩展未装;fetch 时会按需拉起)"
176
+
177
+
178
+ def _masked_config(config: Config) -> dict[str, Any]:
179
+ return {
180
+ "config_path": config.path,
181
+ "schema_version": config.schema_version,
182
+ "providers": [
183
+ {
184
+ "type": p.type,
185
+ "base_url": p.base_url,
186
+ "api_key": mask_secret(p.api_key),
187
+ "model": p.model,
188
+ "timeout": p.timeout,
189
+ **({"concurrency": p.concurrency} if p.type == "locate" else {}),
190
+ }
191
+ for p in config.providers
192
+ ],
193
+ "proxy": {"url": config.proxy.url or "(空=自动检测)"},
194
+ "browser": {
195
+ "channel": config.browser.channel,
196
+ "extension_status": config.browser.extension_status,
197
+ "daemon_port": config.browser.daemon_port,
198
+ "profile_strategy": config.browser.profile_strategy,
199
+ "max_browser_instances": config.browser.max_browser_instances,
200
+ },
201
+ }