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,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)