bookmark-research 0.5.0-beta.1
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/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install, update, or verify Bookmark Research with a selected client's native CLI."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from export_bundle import NAME, SOURCE_ROOT, _copy_plan, read_plugin_manifest
|
|
17
|
+
from host_assets import read_asset
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SELECTOR = NAME + "@" + NAME
|
|
21
|
+
CODEX_HOST_FILES = (
|
|
22
|
+
"hosts/codex/delegate.md",
|
|
23
|
+
"hosts/codex/prepare.py",
|
|
24
|
+
"hosts/shared/research-call.py",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_language(language="auto", environment=None):
|
|
29
|
+
"""Choose installer copy without changing the process locale or user settings."""
|
|
30
|
+
if language not in ("auto", "en", "zh"):
|
|
31
|
+
raise ValueError("--lang must be auto, en, or zh")
|
|
32
|
+
if language != "auto":
|
|
33
|
+
return language
|
|
34
|
+
environment = os.environ if environment is None else environment
|
|
35
|
+
forwarded = environment.get("BOOKMARK_RESEARCH_INSTALL_LANG")
|
|
36
|
+
if forwarded in ("en", "zh"):
|
|
37
|
+
return forwarded
|
|
38
|
+
locale = next((environment.get(key) for key in ("LC_ALL", "LC_MESSAGES", "LANG")
|
|
39
|
+
if environment.get(key)), "en")
|
|
40
|
+
return "zh" if locale.lower().startswith("zh") else "en"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _message(language, english, chinese):
|
|
44
|
+
return chinese if language == "zh" else english
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CodexCli:
|
|
48
|
+
def __init__(self, binary="codex", timeout=60):
|
|
49
|
+
self.binary = binary
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
def run(self, arguments):
|
|
53
|
+
from host_clients import executable
|
|
54
|
+
command = [executable(self.binary) or self.binary, *arguments, "--json"]
|
|
55
|
+
result = subprocess.run(command, text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=self.timeout)
|
|
56
|
+
if result.returncode:
|
|
57
|
+
raise RuntimeError("Codex command failed: " + " ".join(arguments[:3])
|
|
58
|
+
+ "\n" + (result.stderr or result.stdout).strip()[-4000:])
|
|
59
|
+
try:
|
|
60
|
+
value = json.loads(result.stdout)
|
|
61
|
+
except json.JSONDecodeError as error:
|
|
62
|
+
raise RuntimeError("Codex must support plugin commands with --json; update Codex CLI") from error
|
|
63
|
+
if not isinstance(value, dict):
|
|
64
|
+
raise RuntimeError("Unexpected Codex JSON response")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _marketplace(cli):
|
|
69
|
+
value = cli.run(["plugin", "marketplace", "list"])
|
|
70
|
+
entries = value.get("marketplaces")
|
|
71
|
+
if not isinstance(entries, list):
|
|
72
|
+
raise RuntimeError("Codex did not return a marketplaces array")
|
|
73
|
+
matches = [row for row in entries if row.get("name") == NAME]
|
|
74
|
+
if len(matches) > 1:
|
|
75
|
+
raise ValueError("Multiple bookmark-research marketplaces are in scope; resolve their names first")
|
|
76
|
+
return matches[0] if matches else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _installed(cli):
|
|
80
|
+
value = cli.run(["plugin", "list"])
|
|
81
|
+
entries = value.get("installed")
|
|
82
|
+
if not isinstance(entries, list):
|
|
83
|
+
raise RuntimeError("Codex did not return an installed plugins array")
|
|
84
|
+
return next((row for row in entries if row.get("pluginId") == SELECTOR), None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _repository(value):
|
|
88
|
+
"""Normalize public GitHub shorthand while preserving other Git sources."""
|
|
89
|
+
if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value):
|
|
90
|
+
return "https://github.com/" + value.removesuffix(".git") + ".git"
|
|
91
|
+
if value.startswith(("https://", "http://", "ssh://")):
|
|
92
|
+
parsed = urlsplit(value)
|
|
93
|
+
if parsed.password or parsed.query or parsed.fragment or (parsed.username and parsed.scheme != "ssh"):
|
|
94
|
+
raise ValueError("Use a Git URL without credentials, query parameters, or fragments")
|
|
95
|
+
if not parsed.hostname or not parsed.path.strip("/"):
|
|
96
|
+
raise ValueError("Invalid Git repository URL")
|
|
97
|
+
return value.rstrip("/").removesuffix(".git") + ".git"
|
|
98
|
+
if re.fullmatch(r"git@[^\s:]+:[^\s]+", value):
|
|
99
|
+
return value.removesuffix(".git") + ".git"
|
|
100
|
+
raise ValueError("Source must be a local marketplace root, owner/repo, or a Git URL")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _source(value, ref=None):
|
|
104
|
+
value = str(value)
|
|
105
|
+
if not value or any(ord(char) < 32 for char in value):
|
|
106
|
+
raise ValueError("Source must be a nonempty path or Git repository")
|
|
107
|
+
path = Path(value).expanduser()
|
|
108
|
+
if path.exists() or value.startswith(("/", "./", "../", "~", ".\\", "..\\")):
|
|
109
|
+
if ref:
|
|
110
|
+
raise ValueError("--ref only applies to a Git marketplace; checkout local sources yourself")
|
|
111
|
+
root = path.resolve()
|
|
112
|
+
manifest = read_plugin_manifest(root)
|
|
113
|
+
_copy_plan(root)
|
|
114
|
+
for relative in CODEX_HOST_FILES:
|
|
115
|
+
read_asset(root, relative)
|
|
116
|
+
marketplace_path = root / ".agents/plugins/marketplace.json"
|
|
117
|
+
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
|
|
118
|
+
if not isinstance(marketplace, dict) or marketplace.get("name") != NAME:
|
|
119
|
+
raise ValueError("The local marketplace must be named bookmark-research")
|
|
120
|
+
plugin = next((entry for entry in marketplace.get("plugins", []) if entry.get("name") == NAME), None)
|
|
121
|
+
if not plugin or plugin.get("source") != {"source": "local", "path": "./"}:
|
|
122
|
+
raise ValueError("The local marketplace must point bookmark-research at its root (./)")
|
|
123
|
+
return {"sourceType": "local", "source": str(root), "version": manifest["version"]}
|
|
124
|
+
if ref and (ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9_./-]+", ref)):
|
|
125
|
+
raise ValueError("--ref must be a Git branch, tag, or commit identifier")
|
|
126
|
+
return {"sourceType": "git", "source": _repository(value), "ref": ref}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _configured_source(marketplace):
|
|
130
|
+
source = marketplace.get("marketplaceSource")
|
|
131
|
+
if source is None:
|
|
132
|
+
return {"sourceType": "local", "source": str(Path(marketplace["root"]).resolve())}
|
|
133
|
+
if not isinstance(source, dict) or source.get("sourceType") not in ("local", "git"):
|
|
134
|
+
raise RuntimeError("Unsupported Codex marketplace source metadata")
|
|
135
|
+
return source
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_source(marketplace, requested):
|
|
139
|
+
current = _configured_source(marketplace)
|
|
140
|
+
if current["sourceType"] == requested["sourceType"] == "local":
|
|
141
|
+
equal = Path(current["source"]).resolve() == Path(requested["source"]).resolve()
|
|
142
|
+
elif current["sourceType"] == requested["sourceType"] == "git":
|
|
143
|
+
equal = _repository(current["source"]) == requested["source"]
|
|
144
|
+
else:
|
|
145
|
+
equal = False
|
|
146
|
+
if not equal:
|
|
147
|
+
raise ValueError("Marketplace bookmark-research already uses a different source: "
|
|
148
|
+
+ current["source"] + ". Keep it and run update, or explicitly resolve the source in Codex first.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cached_path(installed):
|
|
152
|
+
version = installed.get("version")
|
|
153
|
+
if not isinstance(version, str) or not re.fullmatch(r"[A-Za-z0-9.+_-]+", version):
|
|
154
|
+
raise RuntimeError("Codex did not return a valid installed version")
|
|
155
|
+
profile = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
|
|
156
|
+
return (profile / "plugins/cache" / NAME / NAME / version).resolve()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _runtime_check(root, timeout=30):
|
|
160
|
+
manifest = read_plugin_manifest(root)
|
|
161
|
+
for relative in CODEX_HOST_FILES:
|
|
162
|
+
read_asset(root, relative)
|
|
163
|
+
command = [sys.executable, "-B", str(root / "src/cli.py"), "doctor"]
|
|
164
|
+
doctor = subprocess.run(command, cwd=root.parent, text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout)
|
|
165
|
+
if doctor.returncode:
|
|
166
|
+
raise RuntimeError("Installed runtime doctor failed: " + doctor.stderr.strip()[-2000:])
|
|
167
|
+
health = json.loads(doctor.stdout)
|
|
168
|
+
if health.get("fts5") is not True:
|
|
169
|
+
raise RuntimeError("Installed runtime requires SQLite FTS5")
|
|
170
|
+
server = manifest.get("mcpServers", {}).get(NAME, {})
|
|
171
|
+
if server.get("command") != "python3" or server.get("args") != ["src/cli.py", "serve"] or server.get("cwd") != ".":
|
|
172
|
+
raise ValueError("Unexpected native MCP launch configuration; verify it with Codex directly")
|
|
173
|
+
requests = [
|
|
174
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
175
|
+
"protocolVersion": "2025-11-25", "capabilities": {},
|
|
176
|
+
"clientInfo": {"name": "bookmark-research-installer", "version": manifest["version"]}}},
|
|
177
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
178
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
179
|
+
]
|
|
180
|
+
# Startup now monitors registered live sources. Tool discovery during an
|
|
181
|
+
# installation check must use an empty store, not start syncing user data.
|
|
182
|
+
with tempfile.TemporaryDirectory(prefix="bookmark-runtime-check-") as probe_data:
|
|
183
|
+
environment = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", BOOKMARK_RESEARCH_DATA_DIR=probe_data)
|
|
184
|
+
mcp = subprocess.run([server["command"], *server["args"]], cwd=root,
|
|
185
|
+
input="\n".join(json.dumps(row) for row in requests) + "\n",
|
|
186
|
+
text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout, env=environment)
|
|
187
|
+
if (Path(probe_data) / "index.sqlite3").exists():
|
|
188
|
+
raise RuntimeError("MCP discovery unexpectedly created a database")
|
|
189
|
+
if mcp.returncode:
|
|
190
|
+
raise RuntimeError("Installed MCP startup failed: " + mcp.stderr.strip()[-2000:])
|
|
191
|
+
responses = {row.get("id"): row for row in (json.loads(line) for line in mcp.stdout.splitlines())}
|
|
192
|
+
tools = responses.get(2, {}).get("result", {}).get("tools", [])
|
|
193
|
+
names = sorted(row["name"] for row in tools)
|
|
194
|
+
if not {"search_bookmarks", "search_web", "fetch_web"} <= set(names):
|
|
195
|
+
raise RuntimeError("Installed MCP did not expose the expected bookmark and research tools")
|
|
196
|
+
if "result" not in responses.get(1, {}):
|
|
197
|
+
raise RuntimeError("Installed MCP initialization failed")
|
|
198
|
+
return {"doctor": health, "mcp_protocol": responses[1]["result"]["protocolVersion"],
|
|
199
|
+
"mcp_tools": names, "network_checked": False, "database_created": False}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def verify(cli, installed_path=None):
|
|
203
|
+
installed = _installed(cli)
|
|
204
|
+
if not installed or not installed.get("installed") or not installed.get("enabled"):
|
|
205
|
+
raise ValueError("bookmark-research is not installed and enabled in this Codex profile")
|
|
206
|
+
root = Path(installed_path).expanduser().resolve() if installed_path else _cached_path(installed)
|
|
207
|
+
if not root.is_dir():
|
|
208
|
+
raise ValueError("Installed cache not found; pass --installed-path from codex plugin add --json")
|
|
209
|
+
manifest = read_plugin_manifest(root)
|
|
210
|
+
if manifest["version"] != installed.get("version"):
|
|
211
|
+
raise ValueError("Installed cache version differs from Codex plugin list")
|
|
212
|
+
runtime = _runtime_check(root, timeout=cli.timeout)
|
|
213
|
+
return {"plugin": SELECTOR, "version": manifest["version"], "installed_path": str(root),
|
|
214
|
+
"installed": True, "enabled": True, "verified": True, "runtime": runtime}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _getting_started(installed_path, timeout=30, language="auto"):
|
|
218
|
+
"""Read the installed runtime's preferences without initializing user data."""
|
|
219
|
+
language = resolve_language(language)
|
|
220
|
+
message = lambda english, chinese: _message(language, english, chinese)
|
|
221
|
+
command = [sys.executable, "-B", str(Path(installed_path) / "src/cli.py"), "config", "show"]
|
|
222
|
+
guide = {
|
|
223
|
+
"language": language,
|
|
224
|
+
"first_prompt": message(
|
|
225
|
+
'Use Bookmark Research to read my canvas package at "/absolute/path/to/my-canvas-package". '
|
|
226
|
+
'First list its sections, folders, and bookmark counts offline.',
|
|
227
|
+
'用 Bookmark Research 读取我的书签画布包 "/absolute/path/to/my-canvas-package",先离线列出栏目、文件夹和书签数量。'),
|
|
228
|
+
"settings_command": command,
|
|
229
|
+
"guide_url": "https://github.com/Browser-bookmark-hub/Bookmark-Research/blob/main/docs/" + message(
|
|
230
|
+
"installation.en.md#first-use-and-data-locations", "installation.md#首次使用与数据位置"),
|
|
231
|
+
"configuration": None, "configuration_summary": [], "configuration_error": None,
|
|
232
|
+
}
|
|
233
|
+
try:
|
|
234
|
+
checked = subprocess.run(command, text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout)
|
|
235
|
+
if checked.returncode:
|
|
236
|
+
raise ValueError("Installed config show failed")
|
|
237
|
+
configuration = json.loads(checked.stdout)
|
|
238
|
+
settings = configuration["settings"]
|
|
239
|
+
summary = [
|
|
240
|
+
message("Settings: ", "配置:") + (message("using saved preferences", "沿用已保存的设置")
|
|
241
|
+
if configuration["config_exists"] else message("using defaults; no config file is required", "使用默认值,无需先创建配置文件")),
|
|
242
|
+
message("Search: %s; %s results per target", "搜索:%s;每目标 %s 条结果") % (
|
|
243
|
+
" + ".join(settings["search"]["providers"]), settings["search"]["limit_per_target"]),
|
|
244
|
+
message("Page reading: %s; length parameter %s characters; timeout %s seconds", "正文读取:%s;长度参数 %s 字符;超时 %s 秒") % (
|
|
245
|
+
settings["fetch"]["provider"], settings["fetch"]["max_characters"], settings["timeout_seconds"]),
|
|
246
|
+
message("Archive ordinary page reads: ", "普通网页读取自动归档:") + (
|
|
247
|
+
message("enabled", "开启") if settings["archive"]["enabled"] else message("disabled", "关闭")),
|
|
248
|
+
message("Archive directory: ", "归档目录:") + settings["archive"]["directory"],
|
|
249
|
+
message("Config file: ", "配置文件:") + configuration["config_path"],
|
|
250
|
+
]
|
|
251
|
+
guide.update(configuration=configuration, configuration_summary=summary)
|
|
252
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired):
|
|
253
|
+
# Installation is already verified. Keep an unreadable existing config
|
|
254
|
+
# intact, and avoid exposing its contents or replacing it with defaults.
|
|
255
|
+
guide["configuration_error"] = message(
|
|
256
|
+
"Could not read existing settings. Run the config command below to diagnose and fix the file; the original was preserved.",
|
|
257
|
+
"现有配置读取失败。请运行下方配置查询命令查看原因并修正文件;原文件已保留。")
|
|
258
|
+
return guide
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _print_getting_started(guide):
|
|
262
|
+
language = resolve_language(guide.get("language", "auto"))
|
|
263
|
+
message = lambda english, chinese: _message(language, english, chinese)
|
|
264
|
+
lines = ["", message("Bookmark Research installed; runtime checks passed.", "Bookmark Research 安装完成,运行检查已通过。")]
|
|
265
|
+
if guide["configuration_error"]:
|
|
266
|
+
lines.extend([guide["configuration_error"], message("After fixing the configuration, start here:", "配置修正后,按以下步骤开始:")])
|
|
267
|
+
else:
|
|
268
|
+
lines.extend(guide["configuration_summary"])
|
|
269
|
+
lines.extend([
|
|
270
|
+
"", message("Next steps:", "下一步:"),
|
|
271
|
+
(message("1. Start a new Codex thread to load the plugin.", "1. 在 Codex 新建对话,让客户端加载插件。")
|
|
272
|
+
if guide.get("host", "codex") == "codex" else message(
|
|
273
|
+
"1. Restart %s to load the Skill and tools.", "1. 重启 %s 以加载 Skill 和工具。") % guide["host"]),
|
|
274
|
+
message("2. Prepare your Bookmark Canvas directory, ZIP, or single-card JSON. Replace the path below and send:",
|
|
275
|
+
"2. 准备自己的 Bookmark Canvas 目录、ZIP 或单卡 JSON,把下面的占位路径换成实际路径后发送:"),
|
|
276
|
+
" " + guide["first_prompt"],
|
|
277
|
+
message(" Manual exports become snapshots. For automatic local updates, identify a persistent live directory.",
|
|
278
|
+
" 手动导出会保存为快照;需要自动更新时,说明这是持续同步目录。"),
|
|
279
|
+
message("3. For web-only research, provide a topic directly. Research uses the current host model; no extra model address is required.",
|
|
280
|
+
"3. 只做网页研究时可直接提出主题,无需先导入书签。研究使用当前对话的模型,无需另填模型名称或地址。"),
|
|
281
|
+
"", message("Local queries require no API key. Web access depends on the selected provider's authentication and limits.",
|
|
282
|
+
"本地书签查询不需要 API Key。网页研究使用所选服务,匿名额度和认证要求由服务方决定。"),
|
|
283
|
+
message("When needed, set EXA_API_KEY, PARALLEL_API_KEY, or TAVILY_API_KEY in the environment that launches the client.",
|
|
284
|
+
"需要密钥时,在启动客户端的环境中配置 EXA_API_KEY、PARALLEL_API_KEY 或 TAVILY_API_KEY。"),
|
|
285
|
+
"", message('Optional settings: ask "Show Bookmark Research settings" or "Use only Exa for future searches".',
|
|
286
|
+
"可选配置:在对话中说“显示 Bookmark Research 的配置”,或“以后只用 Exa 搜索”。"),
|
|
287
|
+
message("You can change page archiving preferences in chat. Deep research always preserves its task evidence.",
|
|
288
|
+
"归档偏好也可通过对话修改;深度研究始终保留任务证据。"),
|
|
289
|
+
message("Config command (works from any directory):", "查看配置的终端命令(可从任意目录运行):"),
|
|
290
|
+
" " + shlex.join(guide["settings_command"]),
|
|
291
|
+
message("First use and configuration: ", "首次使用与配置说明:") + guide["guide_url"],
|
|
292
|
+
message("Ask in English or Chinese; specify a different answer/report language when needed. Original quotations are preserved.",
|
|
293
|
+
"可以用中文或英文提问,也可以指定答复和报告语言;原始引用保留原文。"),
|
|
294
|
+
])
|
|
295
|
+
if guide.get("setup_command"):
|
|
296
|
+
lines.extend([message("Reopen guided setup:", "重新打开配置向导:"),
|
|
297
|
+
" " + shlex.join(guide["setup_command"])])
|
|
298
|
+
print("\n".join(lines), file=sys.stderr)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def manage(action, cli, source=None, ref=None, dry_run=False, installed_path=None, language="auto"):
|
|
302
|
+
language = resolve_language(language)
|
|
303
|
+
if action == "verify":
|
|
304
|
+
return verify(cli, installed_path)
|
|
305
|
+
current = _marketplace(cli)
|
|
306
|
+
if action == "install":
|
|
307
|
+
requested = _source(source if source is not None else SOURCE_ROOT, ref)
|
|
308
|
+
if current:
|
|
309
|
+
_check_source(current, requested)
|
|
310
|
+
if ref:
|
|
311
|
+
# Native list JSON omits the registered ref. Equal commit IDs
|
|
312
|
+
# cannot distinguish a moving branch from an immutable tag.
|
|
313
|
+
raise ValueError("An existing Git marketplace retains its registered ref. Reinstall without --ref, or explicitly change the source in Codex first")
|
|
314
|
+
commands = [["plugin", "marketplace", "add", requested["source"]]]
|
|
315
|
+
if ref:
|
|
316
|
+
commands[0].extend(["--ref", ref])
|
|
317
|
+
else:
|
|
318
|
+
if not current or not _installed(cli):
|
|
319
|
+
raise ValueError("No existing bookmark-research installation; run install first")
|
|
320
|
+
requested = _configured_source(current)
|
|
321
|
+
if requested["sourceType"] == "git":
|
|
322
|
+
commands = [["plugin", "marketplace", "upgrade", NAME]]
|
|
323
|
+
else:
|
|
324
|
+
# Validate the retained local source before touching the installed cache.
|
|
325
|
+
requested = _source(requested["source"])
|
|
326
|
+
commands = []
|
|
327
|
+
commands.append(["plugin", "add", SELECTOR])
|
|
328
|
+
plan = {"action": action, "source": requested, "dry_run": dry_run, "language": language,
|
|
329
|
+
"commands": [[cli.binary, *command, "--json"] for command in commands]}
|
|
330
|
+
if dry_run:
|
|
331
|
+
return plan
|
|
332
|
+
added = None
|
|
333
|
+
for command in commands:
|
|
334
|
+
result = cli.run(command)
|
|
335
|
+
if command[:3] == ["plugin", "marketplace", "add"]:
|
|
336
|
+
if result.get("marketplaceName") != NAME:
|
|
337
|
+
raise RuntimeError("The selected source registered a different marketplace; bookmark-research was not installed")
|
|
338
|
+
elif command[:3] == ["plugin", "marketplace", "upgrade"]:
|
|
339
|
+
if result.get("errors"):
|
|
340
|
+
raise RuntimeError("Marketplace upgrade reported errors; the plugin was not reinstalled")
|
|
341
|
+
else:
|
|
342
|
+
added = result
|
|
343
|
+
if not added or not isinstance(added.get("installedPath"), str) or added.get("pluginId") != SELECTOR:
|
|
344
|
+
raise RuntimeError("Codex did not confirm the expected installed plugin path")
|
|
345
|
+
verified = verify(cli, added["installedPath"])
|
|
346
|
+
if requested.get("version") and verified["version"] != requested["version"]:
|
|
347
|
+
raise RuntimeError("Installed plugin version differs from the selected local source")
|
|
348
|
+
if requested["sourceType"] == "local":
|
|
349
|
+
source_root = Path(requested["source"])
|
|
350
|
+
cached_root = Path(verified["installed_path"])
|
|
351
|
+
for relative in [Path(".codex-plugin/plugin.json"), *_copy_plan(source_root, include_codex_metadata=True)]:
|
|
352
|
+
if (source_root / relative).read_bytes() != (cached_root / relative).read_bytes():
|
|
353
|
+
raise RuntimeError("Installed cache differs from the selected source: " + relative.as_posix())
|
|
354
|
+
for relative in CODEX_HOST_FILES:
|
|
355
|
+
if read_asset(source_root, relative) != read_asset(cached_root, relative):
|
|
356
|
+
raise RuntimeError("Installed cache differs from the selected source: " + relative)
|
|
357
|
+
result = {**plan, **verified, "next_step": _message(language,
|
|
358
|
+
"Start a new Codex thread to load the updated Skill and MCP tools.", "在 Codex 新建对话以加载更新后的 Skill 和 MCP 工具。")}
|
|
359
|
+
if action == "install":
|
|
360
|
+
result["getting_started"] = _getting_started(verified["installed_path"], cli.timeout, language)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
HOSTS = ("codex", "claude", "pi", "dsh")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _setup_host(host):
|
|
368
|
+
return "claude_code" if host == "claude" else host
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _normalize_hosts(values):
|
|
372
|
+
"""Accept repeated and comma-separated --host values; dedupe in order."""
|
|
373
|
+
if not values:
|
|
374
|
+
return None
|
|
375
|
+
hosts = []
|
|
376
|
+
for value in values:
|
|
377
|
+
for name in str(value).split(","):
|
|
378
|
+
name = name.strip()
|
|
379
|
+
if not name:
|
|
380
|
+
continue
|
|
381
|
+
if name not in HOSTS:
|
|
382
|
+
raise ValueError("--host must be codex, claude, pi, or dsh: " + name)
|
|
383
|
+
if name not in hosts:
|
|
384
|
+
hosts.append(name)
|
|
385
|
+
if not hosts:
|
|
386
|
+
raise ValueError("--host requires at least one of codex, claude, pi, or dsh")
|
|
387
|
+
return hosts
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _targets_from_args(args):
|
|
391
|
+
hosts = args.host or ["codex"]
|
|
392
|
+
others = [host for host in hosts if host != "codex"]
|
|
393
|
+
if hosts == ["codex"]:
|
|
394
|
+
if any((args.scope, args.project, args.profile, args.install_dir, args.claude, args.pi, args.dsh)):
|
|
395
|
+
raise ValueError("Host-specific options require --host claude, pi, or dsh")
|
|
396
|
+
if others and getattr(args, "installed_path", None):
|
|
397
|
+
raise ValueError("--installed-path is only supported for Codex")
|
|
398
|
+
if len(hosts) == 1:
|
|
399
|
+
# A single host keeps its historical option handling and native errors.
|
|
400
|
+
return [{"host": hosts[0], "scope": args.scope, "project": args.project, "profile": args.profile}]
|
|
401
|
+
# CLI paths for unselected hosts are harmless locators and are ignored.
|
|
402
|
+
if (args.scope or args.project) and not {"claude", "pi"} & set(hosts):
|
|
403
|
+
raise ValueError("--scope and --project require --host claude or pi")
|
|
404
|
+
if args.scope == "local" and "pi" in hosts:
|
|
405
|
+
raise ValueError("Unsupported installation scope for pi: local is Claude-only")
|
|
406
|
+
if args.profile and "dsh" not in hosts:
|
|
407
|
+
raise ValueError("--profile is only supported for DSH")
|
|
408
|
+
if args.install_dir and not others:
|
|
409
|
+
raise ValueError("Host-specific options require --host claude, pi, or dsh")
|
|
410
|
+
return [{"host": host,
|
|
411
|
+
"scope": args.scope if host in ("claude", "pi") else None,
|
|
412
|
+
"project": args.project if host in ("claude", "pi") else None,
|
|
413
|
+
"profile": args.profile if host == "dsh" else None} for host in hosts]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _run_target(args, target, language):
|
|
417
|
+
host = target["host"]
|
|
418
|
+
if host == "codex":
|
|
419
|
+
return manage(args.action, CodexCli(args.codex, args.timeout),
|
|
420
|
+
source=getattr(args, "source", None), ref=getattr(args, "ref", None),
|
|
421
|
+
dry_run=getattr(args, "dry_run", False), installed_path=getattr(args, "installed_path", None),
|
|
422
|
+
language=language)
|
|
423
|
+
from host_install import manage_host
|
|
424
|
+
if getattr(args, "installed_path", None):
|
|
425
|
+
raise ValueError("--installed-path is only supported for Codex")
|
|
426
|
+
result = manage_host(args.action, host, binary=getattr(args, host), timeout=args.timeout,
|
|
427
|
+
source=getattr(args, "source", None), ref=getattr(args, "ref", None),
|
|
428
|
+
dry_run=getattr(args, "dry_run", False), scope=target.get("scope"),
|
|
429
|
+
project=target.get("project"), profile=target.get("profile"), install_dir=args.install_dir)
|
|
430
|
+
result["language"] = language
|
|
431
|
+
return result
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _run_setup(args, installed, host, language, guided):
|
|
435
|
+
command = [sys.executable, "-B", str(installed / "src/cli.py"), "setup", "--lang", language,
|
|
436
|
+
"--host", _setup_host(host), "--interactive" if guided else "--non-interactive"]
|
|
437
|
+
if args.preferences:
|
|
438
|
+
command.extend(["--input", args.preferences])
|
|
439
|
+
if args.skip_checks:
|
|
440
|
+
command.append("--skip-checks")
|
|
441
|
+
if args.test_retrieval:
|
|
442
|
+
command.append("--test-retrieval")
|
|
443
|
+
if not (installed / "src/onboarding.py").is_file():
|
|
444
|
+
requested = guided or bool(args.preferences) or args.test_retrieval
|
|
445
|
+
return {"completed": False if requested else None, "status": "unsupported",
|
|
446
|
+
"message": "The installed version predates guided setup; install a newer version to use setup."}
|
|
447
|
+
# The installed version owns its settings schema. Do not use the
|
|
448
|
+
# bootstrap checkout's runtime or impose a timeout on human input.
|
|
449
|
+
completed = subprocess.run(command, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE)
|
|
450
|
+
try:
|
|
451
|
+
setup = json.loads(completed.stdout)
|
|
452
|
+
if not isinstance(setup, dict):
|
|
453
|
+
raise ValueError("Setup result must be an object")
|
|
454
|
+
except ValueError:
|
|
455
|
+
setup = {"error": "Setup did not return a valid result"}
|
|
456
|
+
setup["completed"] = completed.returncode == 0 and "error" not in setup
|
|
457
|
+
setup["exit_code"] = completed.returncode
|
|
458
|
+
return setup
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _guide(installed, host, timeout, language):
|
|
462
|
+
guide = {**_getting_started(installed, timeout, language), "host": host}
|
|
463
|
+
if (installed / "src/onboarding.py").is_file():
|
|
464
|
+
guide["setup_command"] = ["python3", str(installed / "src/cli.py"), "setup",
|
|
465
|
+
"--host", _setup_host(host), "--lang", language]
|
|
466
|
+
return guide
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main(argv=None):
|
|
470
|
+
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
|
471
|
+
from textio import utf8_stdio
|
|
472
|
+
utf8_stdio()
|
|
473
|
+
language_parser = argparse.ArgumentParser(add_help=False)
|
|
474
|
+
language_parser.add_argument("--lang", choices=("auto", "en", "zh"), default="auto")
|
|
475
|
+
preliminary, _ = language_parser.parse_known_args(argv)
|
|
476
|
+
language = resolve_language(preliminary.lang)
|
|
477
|
+
message = lambda english, chinese: _message(language, english, chinese)
|
|
478
|
+
parser = argparse.ArgumentParser(description=message(__doc__, "通过所选宿主的原生 CLI 安装、更新或验证 Bookmark Research。"))
|
|
479
|
+
language_help = message("Installer language; auto follows LC_ALL, LC_MESSAGES, then LANG", "安装器语言;auto 依次读取 LC_ALL、LC_MESSAGES、LANG")
|
|
480
|
+
parser.add_argument("--lang", choices=("auto", "en", "zh"), default=argparse.SUPPRESS, help=language_help)
|
|
481
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
482
|
+
for action in ("install", "update", "verify"):
|
|
483
|
+
command = subparsers.add_parser(action)
|
|
484
|
+
command.add_argument("--lang", choices=("auto", "en", "zh"), default=argparse.SUPPRESS, help=language_help)
|
|
485
|
+
command.add_argument("--host", action="append", help=message(
|
|
486
|
+
"Target client: codex, claude, pi, or dsh; repeat or comma-separate for several. Guided selection in a terminal, otherwise codex",
|
|
487
|
+
"目标宿主:codex、claude、pi 或 dsh;可重复或用逗号指定多个。终端中引导选择,非交互时默认 codex"))
|
|
488
|
+
command.add_argument("--codex", default="codex", help=message("Codex CLI executable, optionally an absolute path", "Codex CLI 程序名或绝对路径"))
|
|
489
|
+
for host in ("claude", "pi", "dsh"):
|
|
490
|
+
command.add_argument("--" + host, help=message("Path to the " + host + " CLI", host + " CLI 路径"))
|
|
491
|
+
command.add_argument("--scope", choices=("user", "project", "local"), help=message(
|
|
492
|
+
"Claude/Pi scope; default user (local is Claude-only)", "Claude/Pi 作用域,默认 user;local 仅限 Claude"))
|
|
493
|
+
command.add_argument("--project", help=message("Project directory for project/local scope", "project/local 作用域对应的项目目录"))
|
|
494
|
+
command.add_argument("--profile", help=message("Required DSH profile name", "DSH 必填的 profile 名称"))
|
|
495
|
+
command.add_argument("--install-dir", help=message("Managed export/receipt root for non-Codex hosts", "非 Codex 宿主的持久包与安装记录根目录"))
|
|
496
|
+
command.add_argument("--timeout", type=int, default=60, help=message("Timeout for each native command in seconds (1-300)", "每条原生命令的超时秒数(1–300)"))
|
|
497
|
+
if action == "install":
|
|
498
|
+
interaction = command.add_mutually_exclusive_group()
|
|
499
|
+
interaction.add_argument("--interactive", dest="interaction", action="store_const", const="always",
|
|
500
|
+
help=message("Always open the terminal wizard", "始终打开终端向导"))
|
|
501
|
+
interaction.add_argument("--non-interactive", dest="interaction", action="store_const", const="never",
|
|
502
|
+
help=message("Agent/CI mode: explicit flags and JSON output", "Agent/CI 模式:显式参数和 JSON 输出"))
|
|
503
|
+
command.set_defaults(interaction="auto")
|
|
504
|
+
command.add_argument("--preferences", help=message("Preferences JSON file (no credentials)", "偏好 JSON 文件(不含密钥)"))
|
|
505
|
+
command.add_argument("--skip-checks", action="store_true", help=message("Skip network readiness checks", "跳过联网就绪检查"))
|
|
506
|
+
command.add_argument("--test-retrieval", action="store_true", help=message("Test sample search/read; uses retrieval quota", "实际搜索与阅读自检;使用检索额度"))
|
|
507
|
+
command.add_argument("--source", help=message("Local marketplace root or Git repository; defaults to this source tree", "本地 marketplace 根目录或 Git 仓库;默认当前源码目录"))
|
|
508
|
+
command.add_argument("--ref", help=message("Optional Git tag, branch, or commit; local sources must be checked out separately", "可选 Git tag、分支或提交;本地来源需自行 checkout"))
|
|
509
|
+
if action == "verify":
|
|
510
|
+
command.add_argument("--installed-path", help=message("Override the native cache path returned by codex plugin add --json", "指定 codex plugin add --json 返回的安装缓存路径"))
|
|
511
|
+
else:
|
|
512
|
+
command.add_argument("--dry-run", action="store_true", help=message("Read current registration and print commands without installing", "读取登记状态并预览命令,不执行安装"))
|
|
513
|
+
args = parser.parse_args(argv)
|
|
514
|
+
if not 1 <= args.timeout <= 300:
|
|
515
|
+
parser.error(message("--timeout must be between 1 and 300", "--timeout 必须在 1 到 300 之间"))
|
|
516
|
+
try:
|
|
517
|
+
args.host = _normalize_hosts(args.host)
|
|
518
|
+
guided = False
|
|
519
|
+
targets = None
|
|
520
|
+
if args.action == "install":
|
|
521
|
+
if args.skip_checks and args.test_retrieval:
|
|
522
|
+
raise ValueError("--skip-checks cannot be combined with --test-retrieval")
|
|
523
|
+
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
|
524
|
+
if args.preferences:
|
|
525
|
+
from settings import Settings
|
|
526
|
+
if args.preferences == "-":
|
|
527
|
+
raise ValueError("--preferences requires a file path; use cli.py setup --input - for stdin")
|
|
528
|
+
path = Path(args.preferences).expanduser().resolve()
|
|
529
|
+
if path.stat().st_size > 1024 * 1024:
|
|
530
|
+
raise ValueError("Preferences file exceeds the size limit")
|
|
531
|
+
changes = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=Settings._unique_object)
|
|
532
|
+
if not isinstance(changes, dict) or not changes:
|
|
533
|
+
raise ValueError("Preferences must be a nonempty JSON object without credentials")
|
|
534
|
+
# Validate before registering a plugin or changing user settings.
|
|
535
|
+
Settings._validate(Settings._merge(Settings().load(), changes))
|
|
536
|
+
args.preferences = str(path)
|
|
537
|
+
if not args.dry_run:
|
|
538
|
+
from onboarding import Console
|
|
539
|
+
with Console.open(args.interaction, language) as console:
|
|
540
|
+
if console:
|
|
541
|
+
guided = True
|
|
542
|
+
targets = console.install_targets(args)
|
|
543
|
+
if targets is None:
|
|
544
|
+
targets = _targets_from_args(args)
|
|
545
|
+
single = len(targets) == 1
|
|
546
|
+
results = []
|
|
547
|
+
for target in targets:
|
|
548
|
+
try:
|
|
549
|
+
results.append(_run_target(args, target, language))
|
|
550
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
551
|
+
if single:
|
|
552
|
+
raise
|
|
553
|
+
results.append({"host": target["host"], "error": str(error), "verified": False})
|
|
554
|
+
verified = [(target["host"], result) for target, result in zip(targets, results)
|
|
555
|
+
if args.action == "install" and result.get("verified") and "error" not in result]
|
|
556
|
+
setup = None
|
|
557
|
+
if verified:
|
|
558
|
+
first_host, first = verified[0]
|
|
559
|
+
setup = _run_setup(args, Path(first["installed_path"]), first_host, language, guided)
|
|
560
|
+
for host, result in verified:
|
|
561
|
+
result["getting_started"] = _guide(Path(result["installed_path"]), host, args.timeout, language)
|
|
562
|
+
if setup.get("completed") is not True:
|
|
563
|
+
print(message("Plugin installation passed; guided setup is incomplete or unavailable. See setup in the JSON result.",
|
|
564
|
+
"插件安装检查已通过;引导配置未完成或此版本不支持。请查看 JSON 中的 setup 结果。"), file=sys.stderr)
|
|
565
|
+
if single:
|
|
566
|
+
result = results[0]
|
|
567
|
+
if setup is not None:
|
|
568
|
+
result["setup"] = setup
|
|
569
|
+
else:
|
|
570
|
+
result = {"results": results, "language": language}
|
|
571
|
+
if setup is not None:
|
|
572
|
+
result["setup"] = setup
|
|
573
|
+
if args.action == "install" and getattr(args, "dry_run", False) and args.preferences:
|
|
574
|
+
result["preferences_file"] = str(Path(args.preferences).expanduser().resolve())
|
|
575
|
+
result["preferences_applied"] = False
|
|
576
|
+
except KeyboardInterrupt:
|
|
577
|
+
print(json.dumps({"cancelled": True, "message": "Setup cancelled; completed installation steps are retained."}), file=sys.stderr)
|
|
578
|
+
return 130
|
|
579
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
580
|
+
print(json.dumps({"error": str(error), "verified": False}, ensure_ascii=False), file=sys.stderr)
|
|
581
|
+
return 1
|
|
582
|
+
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
|
|
583
|
+
for row in results:
|
|
584
|
+
if row.get("getting_started"):
|
|
585
|
+
_print_getting_started(row["getting_started"])
|
|
586
|
+
if (setup or {}).get("exit_code") == 130:
|
|
587
|
+
return 130
|
|
588
|
+
if any("error" in row for row in results):
|
|
589
|
+
return 1
|
|
590
|
+
return 1 if (setup or {}).get("completed") is False else 0
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
raise SystemExit(main())
|