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.
- package/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""fetch 命令:跨工具爬取增强(PM §4.2 / api-contract §2.2)。
|
|
2
|
+
|
|
3
|
+
research-assistant fetch <url> [<url>...] [--output <path>] [--no-browser]
|
|
4
|
+
[--login|--no-login] [--concurrency N]
|
|
5
|
+
|
|
6
|
+
普通接口(tavily extract / firecrawl scrape,直接出 md)→ 失败回退 headed 浏览器 + CF auto-detect。
|
|
7
|
+
批量 = 单浏览器多页并发(ADR-0004)。落盘只写快照(D8)。
|
|
8
|
+
响应:FetchResult[]{url, method, status, md_path, error?}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .. import config as config_mod
|
|
17
|
+
from ..config import Config
|
|
18
|
+
from ..errors import ArgsError, ResearchAssistantError
|
|
19
|
+
from ..fetch import fetch_normal, fetch_with_browser, write_snapshot
|
|
20
|
+
|
|
21
|
+
NAME = "fetch"
|
|
22
|
+
ALIASES: list[str] = ["f"]
|
|
23
|
+
HELP = "Cross-tool page fetch with browser fallback."
|
|
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("urls", nargs="+", metavar="URL", help="URL(s) to fetch.")
|
|
29
|
+
p.add_argument("--output", help="Write markdown to this path (single URL only).")
|
|
30
|
+
p.add_argument("--no-browser", action="store_true", help="Disable browser fallback.")
|
|
31
|
+
login = p.add_mutually_exclusive_group()
|
|
32
|
+
login.add_argument("--login", action="store_true", default=True, help="Inject login cookies (default).")
|
|
33
|
+
login.add_argument("--no-login", action="store_true", help="Do not inject login cookies.")
|
|
34
|
+
p.add_argument("--concurrency", type=int, default=4, help="Browser page concurrency (default 4).")
|
|
35
|
+
p.set_defaults(_handler=run)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
39
|
+
urls = list(args.urls)
|
|
40
|
+
if args.output and len(urls) > 1:
|
|
41
|
+
raise ArgsError("fetch: --output 仅支持单个 URL")
|
|
42
|
+
|
|
43
|
+
use_login = not getattr(args, "no_login", False)
|
|
44
|
+
|
|
45
|
+
# 1) 普通抓取(并发)
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
normal_results: dict[str, str | None] = {}
|
|
49
|
+
normal_results_list = await asyncio.gather(
|
|
50
|
+
*[fetch_normal(config, u) for u in urls], return_exceptions=True
|
|
51
|
+
)
|
|
52
|
+
for url, res in zip(urls, normal_results_list):
|
|
53
|
+
if isinstance(res, Exception) or res is None:
|
|
54
|
+
normal_results[url] = None
|
|
55
|
+
else:
|
|
56
|
+
normal_results[url] = res
|
|
57
|
+
|
|
58
|
+
# 2) 失败的回退浏览器
|
|
59
|
+
failed = [u for u in urls if normal_results.get(u) is None]
|
|
60
|
+
browser_results: dict[str, str | None] = {}
|
|
61
|
+
if failed and not args.no_browser:
|
|
62
|
+
try:
|
|
63
|
+
browser_results = await fetch_with_browser(
|
|
64
|
+
config, failed, login=use_login, concurrency=args.concurrency
|
|
65
|
+
)
|
|
66
|
+
except ResearchAssistantError:
|
|
67
|
+
browser_results = {u: None for u in failed}
|
|
68
|
+
|
|
69
|
+
# 3) 组装 FetchResult + 落盘
|
|
70
|
+
results: list[dict[str, Any]] = []
|
|
71
|
+
for url in urls:
|
|
72
|
+
md = normal_results.get(url)
|
|
73
|
+
if md is not None:
|
|
74
|
+
method = "normal"
|
|
75
|
+
status = "success"
|
|
76
|
+
error = None
|
|
77
|
+
else:
|
|
78
|
+
md = browser_results.get(url)
|
|
79
|
+
method = "browser"
|
|
80
|
+
if md:
|
|
81
|
+
status = "success"
|
|
82
|
+
error = None
|
|
83
|
+
else:
|
|
84
|
+
status = "failed"
|
|
85
|
+
error = "普通接口与浏览器回退均失败" if not args.no_browser else "普通接口失败且已禁用浏览器回退"
|
|
86
|
+
md_path = None
|
|
87
|
+
if md and status == "success":
|
|
88
|
+
md_path = write_snapshot(url, method, md, args.output)
|
|
89
|
+
results.append(
|
|
90
|
+
{
|
|
91
|
+
"url": url,
|
|
92
|
+
"method": method,
|
|
93
|
+
"status": status,
|
|
94
|
+
"md_path": md_path,
|
|
95
|
+
**({"error": error} if error else {}),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return {"results": results}
|
|
99
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""locate 命令:锚点法速览预筛(PM §4.3)。
|
|
2
|
+
|
|
3
|
+
research-assistant locate <md_path> <query> [--concurrency N] [--top N=5]
|
|
4
|
+
[--scope lines|paragraph] [--context K=3]
|
|
5
|
+
|
|
6
|
+
行号基于已落盘 md 副本(基线 §7.4 稳定性)。
|
|
7
|
+
响应:{md_path, query, anchors[]{quote, line_start, line_end, scope, context, relevance}}
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..config import Config
|
|
17
|
+
from ..errors import ArgsError
|
|
18
|
+
from ..locate import locate as locate_engine
|
|
19
|
+
|
|
20
|
+
NAME = "locate"
|
|
21
|
+
ALIASES: list[str] = []
|
|
22
|
+
HELP = "Anchor-based relevance scan of a local markdown file."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
26
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
27
|
+
p.add_argument("md_path", help="Path to the local markdown file.")
|
|
28
|
+
p.add_argument("query", help="Question / topic to locate.")
|
|
29
|
+
p.add_argument("--concurrency", type=int, default=None, help="Override locate model concurrency.")
|
|
30
|
+
p.add_argument("--top", type=int, default=5, help="Max anchors to return (default 5).")
|
|
31
|
+
p.add_argument("--scope", choices=["lines", "paragraph"], default="paragraph", help="Chunking scope.")
|
|
32
|
+
p.add_argument("--context", type=int, default=3, help="Context lines each side (default 3).")
|
|
33
|
+
p.set_defaults(_handler=run)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
37
|
+
path = Path(args.md_path).expanduser()
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
raise ArgsError(f"locate: 文件不存在: {path}")
|
|
40
|
+
try:
|
|
41
|
+
md_text = path.read_text(encoding="utf-8", errors="replace")
|
|
42
|
+
except OSError as e:
|
|
43
|
+
raise ArgsError(f"locate: 读取失败: {e}") from e
|
|
44
|
+
|
|
45
|
+
if not md_text.strip():
|
|
46
|
+
return {"md_path": str(path), "query": args.query, "anchors": []}
|
|
47
|
+
|
|
48
|
+
anchors = await locate_engine(
|
|
49
|
+
config,
|
|
50
|
+
md_text,
|
|
51
|
+
args.query,
|
|
52
|
+
scope=args.scope,
|
|
53
|
+
top=args.top,
|
|
54
|
+
context_k=args.context,
|
|
55
|
+
concurrency=args.concurrency,
|
|
56
|
+
)
|
|
57
|
+
return {
|
|
58
|
+
"md_path": str(path),
|
|
59
|
+
"query": args.query,
|
|
60
|
+
"anchors": [
|
|
61
|
+
{
|
|
62
|
+
"quote": a.quote,
|
|
63
|
+
"line_start": a.line_start,
|
|
64
|
+
"line_end": a.line_end,
|
|
65
|
+
"scope": a.scope,
|
|
66
|
+
"context": a.context,
|
|
67
|
+
"relevance": a.relevance,
|
|
68
|
+
}
|
|
69
|
+
for a in anchors
|
|
70
|
+
],
|
|
71
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""search 命令:聚合搜索源 API(exa/tavily 等)的统一搜索入口(原义)。
|
|
2
|
+
|
|
3
|
+
research-assistant search "<query>" [--providers exa,tavily] [--limit N]
|
|
4
|
+
|
|
5
|
+
search 是「调搜索 API」的纯粹语义:把 query 发给 exa/tavily(等 web 搜索源 provider)
|
|
6
|
+
各自的 search 能力,聚合去重,返回候选源。它不是 LLM 问答(自然语言意图问答见 ask)。
|
|
7
|
+
|
|
8
|
+
默认查已配置的 exa、tavily;--providers CSV 指定子集或加入 firecrawl(如 exa,firecrawl)。
|
|
9
|
+
|
|
10
|
+
响应:{query, candidates[]{url, title?, snippet?}, sources[]}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .. import providers as providers_pkg
|
|
19
|
+
from ..config import Config
|
|
20
|
+
from ..errors import ArgsError
|
|
21
|
+
|
|
22
|
+
NAME = "search"
|
|
23
|
+
ALIASES: list[str] = []
|
|
24
|
+
HELP = "Aggregate web search across search-source APIs (exa/tavily); not an LLM."
|
|
25
|
+
|
|
26
|
+
# 默认搜索源(顺序即优先级):exa/tavily 需已配置;browser 永远可用(本地浏览器,免配置兜底)。
|
|
27
|
+
# firecrawl 需显式 --providers 加入(有 key 才有意义)。
|
|
28
|
+
_DEFAULT_SOURCES = ("exa", "tavily", "browser")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_wanted(spec: str, configured: set[str]) -> list[str]:
|
|
32
|
+
"""解析要聚合的搜索源:--providers 显式指定;否则已配置的 exa/tavily + browser(免配置兜底)。"""
|
|
33
|
+
spec = (spec or "").strip()
|
|
34
|
+
if spec:
|
|
35
|
+
return [s.strip() for s in spec.split(",") if s.strip()]
|
|
36
|
+
# browser 永远可用(本地浏览器),exa/tavily 需已配置 → 默认永远至少有 browser(开箱即用)
|
|
37
|
+
return [p for p in _DEFAULT_SOURCES if p == "browser" or p in configured]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
41
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
42
|
+
p.add_argument("query", help="Search query (keywords for the search APIs).")
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--providers",
|
|
45
|
+
help="Comma list of search-source providers (default: configured exa,tavily + browser). "
|
|
46
|
+
"e.g. exa,tavily,firecrawl,browser.",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("--limit", type=int, default=5, help="Max results to take per provider (default 5).")
|
|
49
|
+
p.set_defaults(_handler=run)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
53
|
+
wanted = _resolve_wanted(args.providers or "", {p.type for p in config.providers})
|
|
54
|
+
if not wanted:
|
|
55
|
+
raise ArgsError(
|
|
56
|
+
"search: 没有可用的搜索源 provider;请用 --providers 指定",
|
|
57
|
+
provider="search",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
candidates: list[dict[str, Any]] = []
|
|
61
|
+
used: list[str] = []
|
|
62
|
+
for ptype in wanted:
|
|
63
|
+
try:
|
|
64
|
+
extra = await _from_provider(config, ptype, args.query, args.limit)
|
|
65
|
+
except Exception:
|
|
66
|
+
# 单家失败不阻断其余源(如某家未配置/限流);跳过该家继续聚合
|
|
67
|
+
continue
|
|
68
|
+
_merge_unique(candidates, extra)
|
|
69
|
+
used.append(ptype)
|
|
70
|
+
|
|
71
|
+
return {"query": args.query, "candidates": candidates, "sources": used}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _from_provider(
|
|
75
|
+
config: Config, ptype: str, query: str, limit: int
|
|
76
|
+
) -> list[dict[str, Any]]:
|
|
77
|
+
"""调用指定 provider 的 search 能力(复用插件实例),返回候选源列表。"""
|
|
78
|
+
classes = providers_pkg.all_provider_classes()
|
|
79
|
+
pclass = classes.get(ptype)
|
|
80
|
+
if pclass is None:
|
|
81
|
+
raise ArgsError(f"search: 未知 provider '{ptype}'", provider=ptype)
|
|
82
|
+
provider = pclass(config)
|
|
83
|
+
cap = next((c for c in provider.capabilities() if c.name == "search"), None)
|
|
84
|
+
if cap is None:
|
|
85
|
+
raise ArgsError(f"search: provider '{ptype}' 无 search 能力", provider=ptype)
|
|
86
|
+
ns = argparse.Namespace(query=query)
|
|
87
|
+
# 给各 provider search 通用 flag 默认值,避免 AttributeError
|
|
88
|
+
for attr, default in (
|
|
89
|
+
("num_results", limit),
|
|
90
|
+
("max_results", limit),
|
|
91
|
+
("limit", limit),
|
|
92
|
+
("type", "auto"),
|
|
93
|
+
("depth", "basic"),
|
|
94
|
+
("topic", "general"),
|
|
95
|
+
("text", False),
|
|
96
|
+
("highlights", False),
|
|
97
|
+
("include_domains", None),
|
|
98
|
+
("exclude_domains", None),
|
|
99
|
+
("category", None),
|
|
100
|
+
("start_date", None),
|
|
101
|
+
("end_date", None),
|
|
102
|
+
("scrape", False),
|
|
103
|
+
("sources", None),
|
|
104
|
+
("include_subdomains", False),
|
|
105
|
+
("only_main_content", False),
|
|
106
|
+
("wait_for", None),
|
|
107
|
+
("extract_depth", "basic"),
|
|
108
|
+
("format", "markdown"),
|
|
109
|
+
("include_answer", None),
|
|
110
|
+
("time_range", None),
|
|
111
|
+
("include_text", None),
|
|
112
|
+
("exclude_text", None),
|
|
113
|
+
("include_raw_content", None),
|
|
114
|
+
("chunks_per_source", None),
|
|
115
|
+
("country", None),
|
|
116
|
+
("days", None),
|
|
117
|
+
("include_images", False),
|
|
118
|
+
("include_image_descriptions", False),
|
|
119
|
+
("include_favicon", False),
|
|
120
|
+
("auto_parameters", False),
|
|
121
|
+
("engine", "bing-intl"), # browser search 用
|
|
122
|
+
("max_pages", 10), # browser search 用
|
|
123
|
+
):
|
|
124
|
+
if not hasattr(ns, attr):
|
|
125
|
+
setattr(ns, attr, default)
|
|
126
|
+
result = await cap.handler(ns)
|
|
127
|
+
return _normalize_provider_results(result, limit)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalize_provider_results(result: dict[str, Any], limit: int) -> list[dict[str, Any]]:
|
|
131
|
+
"""把各 provider 的 search 响应归一为候选源列表。"""
|
|
132
|
+
items: list[Any] = []
|
|
133
|
+
for key in ("results", "data", "contents"):
|
|
134
|
+
raw = result.get(key)
|
|
135
|
+
if isinstance(raw, list):
|
|
136
|
+
items = raw
|
|
137
|
+
break
|
|
138
|
+
out: list[dict[str, Any]] = []
|
|
139
|
+
for it in items[:limit]:
|
|
140
|
+
if not isinstance(it, dict):
|
|
141
|
+
continue
|
|
142
|
+
url = it.get("url") or it.get("id")
|
|
143
|
+
if not url:
|
|
144
|
+
continue
|
|
145
|
+
item: dict[str, Any] = {"url": url}
|
|
146
|
+
title = it.get("title") or it.get("name")
|
|
147
|
+
if title:
|
|
148
|
+
item["title"] = title
|
|
149
|
+
snippet = it.get("content") or it.get("text") or it.get("snippet") or it.get("description")
|
|
150
|
+
if snippet:
|
|
151
|
+
item["snippet"] = snippet
|
|
152
|
+
out.append(item)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _merge_unique(target: list[dict[str, Any]], extras: list[dict[str, Any]]) -> None:
|
|
157
|
+
seen = {c["url"] for c in target}
|
|
158
|
+
for e in extras:
|
|
159
|
+
if e["url"] not in seen:
|
|
160
|
+
target.append(e)
|
|
161
|
+
seen.add(e["url"])
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""setup 命令:配置 provider/代理/浏览器 + 可选安装 managed skill/agent(api-contract §2.3)。
|
|
2
|
+
|
|
3
|
+
交互式:逐项问答(key 用 getpass 遮蔽输入)。
|
|
4
|
+
非交互式:
|
|
5
|
+
--config-inline "<toml>" 直接写整份 TOML
|
|
6
|
+
--provider type[,k=v,k=v]... 重复添加/更新 provider
|
|
7
|
+
--proxy <url> --browser-channel <c> --daemon-port <n>
|
|
8
|
+
幂等:重复 setup 合并更新配置;重复 install 覆盖 managed 文件。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import getpass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..config import Config, ProviderConfig, load, save
|
|
18
|
+
from ..errors import ArgsError, ResearchAssistantError
|
|
19
|
+
from .. import targets as targets_mod
|
|
20
|
+
from .. import installer
|
|
21
|
+
|
|
22
|
+
NAME = "setup"
|
|
23
|
+
ALIASES: list[str] = ["init"]
|
|
24
|
+
HELP = "Configure providers/proxy/browser and optionally install managed skill/agent."
|
|
25
|
+
|
|
26
|
+
# 已知 provider 类型 + 是否需要 model
|
|
27
|
+
_PROVIDER_TYPES: dict[str, dict[str, Any]] = {
|
|
28
|
+
"openai_compat": {"needs_model": True, "label": "OpenAI-compatible (search/LLM)", "default_base": ""},
|
|
29
|
+
"locate": {"needs_model": True, "label": "locate 模型 (openai 兼容小模型)", "default_base": ""},
|
|
30
|
+
"exa": {"needs_model": False, "label": "Exa", "default_base": "https://api.exa.ai"},
|
|
31
|
+
"tavily": {"needs_model": False, "label": "Tavily", "default_base": "https://api.tavily.com"},
|
|
32
|
+
"firecrawl": {"needs_model": False, "label": "Firecrawl", "default_base": "https://api.firecrawl.dev/v2"},
|
|
33
|
+
"context7": {"needs_model": False, "label": "Context7", "default_base": "https://context7.com"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
38
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
39
|
+
mode = p.add_mutually_exclusive_group()
|
|
40
|
+
mode.add_argument("--non-interactive", action="store_true", help="Non-interactive (use flags/inline).")
|
|
41
|
+
mode.add_argument("--config-inline", help="Write a full TOML config string (non-interactive).")
|
|
42
|
+
p.add_argument(
|
|
43
|
+
"--provider",
|
|
44
|
+
action="append",
|
|
45
|
+
metavar="TYPE[,k=v,...]",
|
|
46
|
+
help="Add/update a provider, e.g. exa,api_key=... or openai_compat,base_url=...,api_key=...,model=...",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("--proxy", help="Proxy URL (empty=auto-detect; 'none' to clear).")
|
|
49
|
+
p.add_argument("--browser-channel", choices=["msedge", "chrome"], help="Browser channel for fetch fallback.")
|
|
50
|
+
p.add_argument("--daemon-port", type=int, help="Cookie daemon port.")
|
|
51
|
+
p.add_argument("--install-skills", help="Comma list of families to install (claude,codex,cursor,hermes,all).")
|
|
52
|
+
p.add_argument("--skills-root", help="Override install root (default $HOME).")
|
|
53
|
+
p.set_defaults(_handler=run)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
57
|
+
inline = getattr(args, "config_inline", None)
|
|
58
|
+
if inline:
|
|
59
|
+
cfg = _config_from_inline(inline, config.path)
|
|
60
|
+
elif getattr(args, "non_interactive", False):
|
|
61
|
+
cfg = _config_from_flags(args, config)
|
|
62
|
+
else:
|
|
63
|
+
cfg = _interactive(config)
|
|
64
|
+
|
|
65
|
+
saved_path = save(cfg, config.path)
|
|
66
|
+
|
|
67
|
+
result: dict[str, Any] = {"config_path": str(saved_path), "providers": [p.type for p in cfg.providers]}
|
|
68
|
+
|
|
69
|
+
# 可选:安装 managed skill/agent
|
|
70
|
+
if args.install_skills:
|
|
71
|
+
families = _parse_families(args.install_skills)
|
|
72
|
+
root = args.skills_root
|
|
73
|
+
install_result = installer.install(families, root=root)
|
|
74
|
+
result["install"] = install_result
|
|
75
|
+
# 装了扩展桥时标记 browser.extension_status
|
|
76
|
+
if install_result.get("extension_copied"):
|
|
77
|
+
cfg.browser.extension_status = "installed"
|
|
78
|
+
save(cfg, config.path)
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# 非交互式
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _config_from_inline(toml_str: str, current_path: str) -> Config:
|
|
89
|
+
import sys
|
|
90
|
+
|
|
91
|
+
if sys.version_info >= (3, 11):
|
|
92
|
+
import tomllib
|
|
93
|
+
else: # pragma: no cover
|
|
94
|
+
import tomli as tomllib
|
|
95
|
+
try:
|
|
96
|
+
raw = tomllib.loads(toml_str)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise ArgsError(f"setup: --config-inline TOML 解析失败: {e}") from e
|
|
99
|
+
# 复用 config.load 的解析逻辑:写临时再加载
|
|
100
|
+
import tempfile
|
|
101
|
+
|
|
102
|
+
with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
|
|
103
|
+
f.write(toml_str)
|
|
104
|
+
tmp = f.name
|
|
105
|
+
try:
|
|
106
|
+
return load(tmp)
|
|
107
|
+
finally:
|
|
108
|
+
try:
|
|
109
|
+
import os
|
|
110
|
+
|
|
111
|
+
os.unlink(tmp)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _config_from_flags(args: argparse.Namespace, current: Config) -> Config:
|
|
117
|
+
cfg = current # 在现有基础上合并(幂等)
|
|
118
|
+
if args.provider:
|
|
119
|
+
for spec in args.provider:
|
|
120
|
+
pc = _parse_provider_spec(spec)
|
|
121
|
+
_upsert_provider(cfg, pc)
|
|
122
|
+
if args.proxy is not None:
|
|
123
|
+
cfg.proxy.url = "" if args.proxy.lower() in ("none", "") else args.proxy
|
|
124
|
+
if args.browser_channel:
|
|
125
|
+
cfg.browser.channel = args.browser_channel
|
|
126
|
+
if args.daemon_port:
|
|
127
|
+
cfg.browser.daemon_port = args.daemon_port
|
|
128
|
+
return cfg
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_provider_spec(spec: str) -> ProviderConfig:
|
|
132
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
133
|
+
if not parts:
|
|
134
|
+
raise ArgsError("setup: --provider 缺少 type")
|
|
135
|
+
ptype = parts[0]
|
|
136
|
+
if ptype not in _PROVIDER_TYPES:
|
|
137
|
+
raise ArgsError(f"setup: 未知 provider type '{ptype}'")
|
|
138
|
+
kv: dict[str, str] = {}
|
|
139
|
+
for token in parts[1:]:
|
|
140
|
+
if "=" not in token:
|
|
141
|
+
raise ArgsError(f"setup: --provider 错误的 k=v 片段 '{token}'")
|
|
142
|
+
k, v = token.split("=", 1)
|
|
143
|
+
kv[k.strip()] = v.strip()
|
|
144
|
+
return ProviderConfig(
|
|
145
|
+
type=ptype,
|
|
146
|
+
base_url=kv.get("base_url", _PROVIDER_TYPES[ptype]["default_base"] or ""),
|
|
147
|
+
api_key=kv.get("api_key", ""),
|
|
148
|
+
model=kv.get("model", ""),
|
|
149
|
+
timeout=int(kv.get("timeout", "30")),
|
|
150
|
+
concurrency=int(kv.get("concurrency", "8")),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _upsert_provider(cfg: Config, pc: ProviderConfig) -> None:
|
|
155
|
+
for i, existing in enumerate(cfg.providers):
|
|
156
|
+
if existing.type == pc.type:
|
|
157
|
+
cfg.providers[i] = pc
|
|
158
|
+
return
|
|
159
|
+
cfg.providers.append(pc)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_families(raw: str) -> list[str]:
|
|
163
|
+
tokens = [t.strip().lower() for t in raw.replace(";", ",").split(",") if t.strip()]
|
|
164
|
+
if not tokens:
|
|
165
|
+
return []
|
|
166
|
+
out: list[str] = []
|
|
167
|
+
for t in tokens:
|
|
168
|
+
if t in ("all", "全部"):
|
|
169
|
+
return list(targets_mod.ALL_FAMILIES.keys())
|
|
170
|
+
if t in targets_mod.ALL_FAMILIES:
|
|
171
|
+
out.append(t)
|
|
172
|
+
else:
|
|
173
|
+
raise ArgsError(f"setup: 未知 install 目标 '{t}'(可选: {','.join(targets_mod.ALL_FAMILIES)})")
|
|
174
|
+
# 去重保序
|
|
175
|
+
seen, dedup = set(), []
|
|
176
|
+
for t in out:
|
|
177
|
+
if t not in seen:
|
|
178
|
+
seen.add(t)
|
|
179
|
+
dedup.append(t)
|
|
180
|
+
return dedup
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# 交互式
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _interactive(current: Config) -> Config:
|
|
189
|
+
cfg = current
|
|
190
|
+
print("\nresearch-assistant setup(交互式,回车保留当前值)\n")
|
|
191
|
+
for ptype, meta in _PROVIDER_TYPES.items():
|
|
192
|
+
existing = next((p for p in cfg.providers if p.type == ptype), None)
|
|
193
|
+
if _ask_enable(meta["label"], existing is not None):
|
|
194
|
+
pc = _prompt_provider(ptype, meta, existing)
|
|
195
|
+
_upsert_provider(cfg, pc)
|
|
196
|
+
# 代理
|
|
197
|
+
cur = cfg.proxy.url or "(空=自动检测)"
|
|
198
|
+
val = input(f"\n代理 URL [{cur}]: ").strip()
|
|
199
|
+
if val:
|
|
200
|
+
cfg.proxy.url = "" if val.lower() in ("none", "无") else val
|
|
201
|
+
# 浏览器
|
|
202
|
+
cur_ch = cfg.browser.channel
|
|
203
|
+
ch = input(f"浏览器 channel (msedge|chrome) [{cur_ch}]: ").strip().lower()
|
|
204
|
+
if ch in ("msedge", "chrome"):
|
|
205
|
+
cfg.browser.channel = ch
|
|
206
|
+
cur_port = cfg.browser.daemon_port
|
|
207
|
+
port = input(f"cookie daemon 端口 [{cur_port}]: ").strip()
|
|
208
|
+
if port.isdigit():
|
|
209
|
+
cfg.browser.daemon_port = int(port)
|
|
210
|
+
return cfg
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _ask_enable(label: str, currently_enabled: bool) -> bool:
|
|
214
|
+
default = "y" if currently_enabled else "n"
|
|
215
|
+
ans = input(f"\n配置 {label}? [y/N] ({default}): ").strip().lower()
|
|
216
|
+
if not ans:
|
|
217
|
+
return currently_enabled
|
|
218
|
+
return ans in ("y", "yes", "是")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _prompt_provider(ptype: str, meta: dict[str, Any], existing: ProviderConfig | None) -> ProviderConfig:
|
|
222
|
+
cur_base = existing.base_url if existing else meta["default_base"]
|
|
223
|
+
cur_key = existing.api_key if existing else ""
|
|
224
|
+
cur_model = existing.model if existing else ""
|
|
225
|
+
cur_timeout = existing.timeout if existing else 30
|
|
226
|
+
|
|
227
|
+
base = input(f" base_url [{cur_base or '必填'}]: ").strip() or cur_base
|
|
228
|
+
key = getpass.getpass(f" api_key [{'已配置' if cur_key else '必填'}]: ").strip() or cur_key
|
|
229
|
+
model = ""
|
|
230
|
+
if meta["needs_model"]:
|
|
231
|
+
model = input(f" model [{cur_model or '必填'}]: ").strip() or cur_model
|
|
232
|
+
timeout_s = input(f" timeout 秒 [{cur_timeout}]: ").strip()
|
|
233
|
+
timeout = int(timeout_s) if timeout_s.isdigit() else cur_timeout
|
|
234
|
+
concurrency = 8
|
|
235
|
+
if ptype == "locate":
|
|
236
|
+
cc = input(f" concurrency [{existing.concurrency if existing else 8}]: ").strip()
|
|
237
|
+
if cc.isdigit():
|
|
238
|
+
concurrency = int(cc)
|
|
239
|
+
elif existing:
|
|
240
|
+
concurrency = existing.concurrency
|
|
241
|
+
|
|
242
|
+
return ProviderConfig(
|
|
243
|
+
type=ptype,
|
|
244
|
+
base_url=base,
|
|
245
|
+
api_key=key,
|
|
246
|
+
model=model,
|
|
247
|
+
timeout=timeout,
|
|
248
|
+
concurrency=concurrency,
|
|
249
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""skills 命令:managed skill/agent 的同步(api-contract §2.3)。
|
|
2
|
+
|
|
3
|
+
research-assistant skills status [--targets list]
|
|
4
|
+
research-assistant skills update [--targets list] [--skills-root path]
|
|
5
|
+
|
|
6
|
+
status 判定 missing/stale/up-to-date/extra;update 刷新 managed 文件。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .. import installer
|
|
15
|
+
from ..config import Config
|
|
16
|
+
from ..errors import ArgsError
|
|
17
|
+
from ..targets import ALL_FAMILIES
|
|
18
|
+
|
|
19
|
+
NAME = "skills"
|
|
20
|
+
ALIASES: list[str] = ["skill"]
|
|
21
|
+
HELP = "Manage managed skill/agent files across AI agent families."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
25
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
26
|
+
sub = p.add_subparsers(dest="subcommand", required=True, metavar="<status|update>")
|
|
27
|
+
st = sub.add_parser("status", help="Check managed file freshness.")
|
|
28
|
+
st.add_argument("--targets", help=f"Comma list (default all: {','.join(ALL_FAMILIES)}).")
|
|
29
|
+
st.add_argument("--skills-root", help="Override install root (default $HOME).")
|
|
30
|
+
st.set_defaults(_handler=run_status)
|
|
31
|
+
up = sub.add_parser("update", help="Refresh managed files.")
|
|
32
|
+
up.add_argument("--targets", help=f"Comma list (default all: {','.join(ALL_FAMILIES)}).")
|
|
33
|
+
up.add_argument("--skills-root", help="Override install root (default $HOME).")
|
|
34
|
+
up.set_defaults(_handler=run_update)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_targets(raw: str | None) -> list[str]:
|
|
38
|
+
if not raw:
|
|
39
|
+
return list(ALL_FAMILIES.keys())
|
|
40
|
+
tokens = [t.strip().lower() for t in raw.replace(";", ",").split(",") if t.strip()]
|
|
41
|
+
if not tokens:
|
|
42
|
+
return list(ALL_FAMILIES.keys())
|
|
43
|
+
if "all" in tokens:
|
|
44
|
+
return list(ALL_FAMILIES.keys())
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for t in tokens:
|
|
47
|
+
if t not in ALL_FAMILIES:
|
|
48
|
+
raise ArgsError(f"skills: 未知目标 '{t}'(可选: {','.join(ALL_FAMILIES)})")
|
|
49
|
+
out.append(t)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def run_status(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
54
|
+
families = _resolve_targets(args.targets)
|
|
55
|
+
return installer.status(families, root=args.skills_root)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def run_update(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
59
|
+
families = _resolve_targets(args.targets)
|
|
60
|
+
return installer.update(families, root=args.skills_root)
|