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,70 @@
|
|
|
1
|
+
"""Assemble host-owned workflows; this module never starts a host or an agent."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
HOST_SOURCE_FILES = (
|
|
8
|
+
"hosts/shared/workflow.json", "hosts/shared/research-flow.js", "hosts/shared/research-call.py",
|
|
9
|
+
"hosts/claude/runtime.js", "hosts/dsh/runtime.js", "hosts/dsh/workflow-call.py", "hosts/dsh/plugin.js",
|
|
10
|
+
"hosts/pi/runtime.js", "hosts/pi/register-workflow.py",
|
|
11
|
+
"hosts/codex/delegate.md", "hosts/codex/prepare.py",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_asset(root, relative):
|
|
16
|
+
"""Read a curated asset without following source symlinks."""
|
|
17
|
+
current = Path(root)
|
|
18
|
+
for part in Path(relative).parts:
|
|
19
|
+
current = current / part
|
|
20
|
+
if current.is_symlink():
|
|
21
|
+
raise ValueError("Source symlinks are not exported: " + relative)
|
|
22
|
+
if not current.is_file():
|
|
23
|
+
raise ValueError("Required host asset is missing: " + relative)
|
|
24
|
+
return current.read_bytes()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_workflow(root, host):
|
|
28
|
+
if host not in ("claude", "dsh", "pi"):
|
|
29
|
+
raise ValueError("Unknown workflow host: " + str(host))
|
|
30
|
+
definition = json.loads(read_asset(root, "hosts/shared/workflow.json"))
|
|
31
|
+
script = read_asset(root, "hosts/" + host + "/runtime.js").decode("utf-8")
|
|
32
|
+
script += "\n" + read_asset(root, "hosts/shared/research-flow.js").decode("utf-8")
|
|
33
|
+
if host == "claude":
|
|
34
|
+
script = "export const meta = " + json.dumps(definition["meta"], ensure_ascii=False, indent=2) + ";\n\n" + script
|
|
35
|
+
return definition, script
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def export_host_assets(source, stage, host):
|
|
39
|
+
"""Return concrete, host-specific assets in the staged distribution."""
|
|
40
|
+
if host == "agent-plugin":
|
|
41
|
+
return [] # Do not mix client-specific components into the standard format.
|
|
42
|
+
files = {"hosts/shared/research-call.py": read_asset(source, "hosts/shared/research-call.py")}
|
|
43
|
+
if host == "codex":
|
|
44
|
+
for name in ("delegate.md", "prepare.py"):
|
|
45
|
+
relative = "hosts/codex/" + name
|
|
46
|
+
files[relative] = read_asset(source, relative)
|
|
47
|
+
else:
|
|
48
|
+
definition, script = build_workflow(source, host)
|
|
49
|
+
if host == "claude":
|
|
50
|
+
files["workflows/bookmark-research.js"] = script.encode("utf-8")
|
|
51
|
+
else:
|
|
52
|
+
files["workflows/bookmark-research/script.js"] = script.encode("utf-8")
|
|
53
|
+
if host == "pi":
|
|
54
|
+
parameters = definition["parameters"]
|
|
55
|
+
parameters["bridge_path"]["required"] = True
|
|
56
|
+
workflow = {**definition["meta"], "parameters": parameters}
|
|
57
|
+
files["workflows/bookmark-research/workflow.json"] = (
|
|
58
|
+
json.dumps(workflow, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
59
|
+
relative = "hosts/pi/register-workflow.py"
|
|
60
|
+
else:
|
|
61
|
+
files["hosts/dsh/plugin.js"] = read_asset(source, "hosts/dsh/plugin.js")
|
|
62
|
+
files["workflows/bookmark-research/meta.json"] = (
|
|
63
|
+
json.dumps(definition["meta"], ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
64
|
+
relative = "hosts/dsh/workflow-call.py"
|
|
65
|
+
files[relative] = read_asset(source, relative)
|
|
66
|
+
for relative, content in files.items():
|
|
67
|
+
destination = stage / relative
|
|
68
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
destination.write_bytes(content)
|
|
70
|
+
return sorted(files)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Native client commands and read-only registration checks for exported hosts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from export_bundle import NAME
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SELECTOR = NAME + "@" + NAME
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def executable(binary):
|
|
16
|
+
"""Resolve a CLI, including .cmd/.exe shims that Windows Python 3.9 misses for full paths."""
|
|
17
|
+
found = shutil.which(binary)
|
|
18
|
+
if os.name == "nt" and (not found or not Path(found).suffix):
|
|
19
|
+
for extension in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";"):
|
|
20
|
+
found = shutil.which(binary + extension) or found
|
|
21
|
+
if found and Path(found).suffix:
|
|
22
|
+
break
|
|
23
|
+
return found
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_json(path, default=None):
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return default
|
|
29
|
+
if path.is_symlink():
|
|
30
|
+
raise ValueError("Refusing a symlink state file: " + str(path))
|
|
31
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def local_path(value, base):
|
|
35
|
+
if not isinstance(value, str):
|
|
36
|
+
return None
|
|
37
|
+
for prefix in ("link:", "file:"):
|
|
38
|
+
if value.startswith(prefix):
|
|
39
|
+
value = value[len(prefix):]
|
|
40
|
+
if ":" in value and not value.startswith("/") and not Path(value).is_absolute():
|
|
41
|
+
return None
|
|
42
|
+
path = Path(value).expanduser()
|
|
43
|
+
return (path if path.is_absolute() else base / path).resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HostClient:
|
|
47
|
+
def __init__(self, host, binary, timeout, scope=None, project=None, profile=None):
|
|
48
|
+
self.host, self.binary, self.timeout = host, binary, timeout
|
|
49
|
+
self.scope = scope or "user"
|
|
50
|
+
self.cwd = Path(project).expanduser().resolve() if project else Path.cwd()
|
|
51
|
+
self.profile = profile
|
|
52
|
+
if host == "dsh":
|
|
53
|
+
if scope or project:
|
|
54
|
+
raise ValueError("DSH uses --profile, not --scope or --project")
|
|
55
|
+
if not profile or not all(c.isalnum() or c in "._-" for c in profile) or profile.startswith("."):
|
|
56
|
+
raise ValueError("DSH requires --profile NAME (letters, numbers, dots, underscores or hyphens)")
|
|
57
|
+
self.home = Path(os.environ.get("DSH_HOME", str(Path.home() / ".dsh"))).expanduser().resolve()
|
|
58
|
+
self.settings = self.home / "profiles" / profile / "package.json"
|
|
59
|
+
else:
|
|
60
|
+
if profile:
|
|
61
|
+
raise ValueError("--profile is only supported for DSH")
|
|
62
|
+
if self.scope not in (("user", "project", "local") if host == "claude" else ("user", "project")):
|
|
63
|
+
raise ValueError("Unsupported installation scope for " + host)
|
|
64
|
+
if self.scope != "user" and not project:
|
|
65
|
+
raise ValueError("Project/local scope requires --project PATH")
|
|
66
|
+
if not self.cwd.is_dir():
|
|
67
|
+
raise ValueError("Project directory does not exist: " + str(self.cwd))
|
|
68
|
+
variable, default = (("CLAUDE_CONFIG_DIR", Path.home() / ".claude") if host == "claude"
|
|
69
|
+
else ("PI_CODING_AGENT_DIR", Path.home() / ".pi/agent"))
|
|
70
|
+
self.home = Path(os.environ.get(variable, str(default))).expanduser().resolve()
|
|
71
|
+
self.settings = (self.home / "settings.json" if self.scope == "user"
|
|
72
|
+
else self.cwd / ".pi/settings.json")
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def target(self):
|
|
76
|
+
return {"host": self.host, "client_home": str(self.home),
|
|
77
|
+
"scope": self.scope if self.host != "dsh" else None,
|
|
78
|
+
"project": str(self.cwd) if self.host != "dsh" and self.scope != "user" else None,
|
|
79
|
+
"profile": self.profile}
|
|
80
|
+
|
|
81
|
+
def require(self):
|
|
82
|
+
if not executable(self.binary):
|
|
83
|
+
raise ValueError(self.host + " CLI was not found: " + self.binary)
|
|
84
|
+
if self.host == "dsh" and not shutil.which("pnpm"):
|
|
85
|
+
# dsh plugin forwards to pnpm; fail before creating a profile.
|
|
86
|
+
raise ValueError("DSH installs profile plugins with pnpm, which was not found on PATH; "
|
|
87
|
+
"install it (npm install -g pnpm, or corepack enable pnpm) and retry")
|
|
88
|
+
|
|
89
|
+
def run(self, arguments, structured=False, quiet_error=False):
|
|
90
|
+
result = subprocess.run([executable(self.binary) or self.binary, *arguments], cwd=self.cwd, text=True, encoding="utf-8", errors="replace",
|
|
91
|
+
capture_output=True, timeout=self.timeout)
|
|
92
|
+
if result.returncode:
|
|
93
|
+
detail = "" if quiet_error else "\n" + (result.stderr or result.stdout).strip()[-3000:]
|
|
94
|
+
raise RuntimeError(self.host + " command failed: " + " ".join(arguments[:3]) + detail)
|
|
95
|
+
if not structured:
|
|
96
|
+
return result.stdout
|
|
97
|
+
return json.loads(result.stdout)
|
|
98
|
+
|
|
99
|
+
def registration(self, bundle):
|
|
100
|
+
"""Reject another source before mutations, and return this target's native state."""
|
|
101
|
+
if self.host == "claude":
|
|
102
|
+
marketplaces = self.run(["plugin", "marketplace", "list", "--json"], True)
|
|
103
|
+
plugins = self.run(["plugin", "list", "--json"], True)
|
|
104
|
+
if not isinstance(marketplaces, list) or not isinstance(plugins, list):
|
|
105
|
+
raise ValueError("Unexpected Claude plugin list JSON")
|
|
106
|
+
if any(not isinstance(row, dict) for row in marketplaces + plugins):
|
|
107
|
+
raise ValueError("Unexpected Claude plugin list entry")
|
|
108
|
+
matches = [row for row in marketplaces if row.get("name") == NAME]
|
|
109
|
+
if len(matches) > 1 or (matches and (matches[0].get("source") != "directory"
|
|
110
|
+
or local_path(matches[0].get("path"), self.cwd) != bundle.resolve())):
|
|
111
|
+
raise ValueError("Claude marketplace bookmark-research uses another source; resolve it in Claude first")
|
|
112
|
+
other = [row for row in plugins if row.get("scope") == self.scope
|
|
113
|
+
and str(row.get("id", "")).startswith(NAME + "@") and row.get("id") != SELECTOR]
|
|
114
|
+
if other:
|
|
115
|
+
raise ValueError("Bookmark Research is already installed from another Claude marketplace")
|
|
116
|
+
rows = [row for row in plugins if row.get("id") == SELECTOR and row.get("scope") == self.scope]
|
|
117
|
+
if len(rows) > 1:
|
|
118
|
+
raise ValueError("Multiple Claude registrations match this scope")
|
|
119
|
+
return {"marketplace": bool(matches), "installed": bool(rows), "plugin": rows[0] if rows else None}
|
|
120
|
+
settings = read_json(self.settings, {})
|
|
121
|
+
if not isinstance(settings, dict):
|
|
122
|
+
raise ValueError("Unexpected client settings object: " + str(self.settings))
|
|
123
|
+
if self.host == "pi":
|
|
124
|
+
packages = settings.get("packages", [])
|
|
125
|
+
if not isinstance(packages, list):
|
|
126
|
+
raise ValueError("Pi packages must be an array")
|
|
127
|
+
matches = []
|
|
128
|
+
for entry in packages:
|
|
129
|
+
source = entry.get("source") if isinstance(entry, dict) else entry
|
|
130
|
+
path = local_path(source, self.settings.parent)
|
|
131
|
+
if path == bundle.resolve():
|
|
132
|
+
if isinstance(entry, dict) and (entry.get("skills") == [] or entry.get("autoload") is False):
|
|
133
|
+
raise ValueError("The registered Pi package disables its Skill; enable it in Pi first")
|
|
134
|
+
matches.append(entry)
|
|
135
|
+
elif path and path.is_dir():
|
|
136
|
+
metadata = read_json(path / "package.json", {})
|
|
137
|
+
if isinstance(metadata, dict) and metadata.get("name") == NAME:
|
|
138
|
+
raise ValueError("Pi already registers Bookmark Research at another path")
|
|
139
|
+
elif isinstance(source, str) and ("/Bookmark-Research" in source or source.startswith("npm:" + NAME)):
|
|
140
|
+
raise ValueError("Pi already registers another Bookmark Research source")
|
|
141
|
+
if len(matches) > 1:
|
|
142
|
+
raise ValueError("Duplicate Pi registrations; resolve them in Pi first")
|
|
143
|
+
return {"installed": bool(matches)}
|
|
144
|
+
dependencies, dsh = settings.get("dependencies", {}), settings.get("dsh", {})
|
|
145
|
+
if not isinstance(dependencies, dict) or not isinstance(dsh, dict) or not isinstance(dsh.get("profile", {}), dict):
|
|
146
|
+
raise ValueError("Unexpected DSH profile manifest")
|
|
147
|
+
dependency = dependencies.get(NAME)
|
|
148
|
+
if dependency and local_path(dependency, self.settings.parent) != bundle.resolve():
|
|
149
|
+
raise ValueError("DSH profile already installs Bookmark Research from another source")
|
|
150
|
+
bundles = settings.get("dsh", {}).get("profile", {}).get("bundles", [])
|
|
151
|
+
if not isinstance(bundles, list):
|
|
152
|
+
raise ValueError("DSH profile bundles must be an array")
|
|
153
|
+
return {"installed": bool(dependency), "enabled": NAME in bundles}
|
|
154
|
+
|
|
155
|
+
def commands(self, bundle, registration, changed=False):
|
|
156
|
+
if self.host == "claude":
|
|
157
|
+
commands = []
|
|
158
|
+
if not registration["marketplace"]:
|
|
159
|
+
commands.append(["plugin", "marketplace", "add", str(bundle), "--scope", self.scope])
|
|
160
|
+
if registration["installed"]:
|
|
161
|
+
if changed:
|
|
162
|
+
commands.extend([["plugin", "marketplace", "update", NAME],
|
|
163
|
+
["plugin", "update", SELECTOR, "--scope", self.scope]])
|
|
164
|
+
if not registration["plugin"].get("enabled"):
|
|
165
|
+
raise ValueError("The Claude plugin is disabled; enable it in Claude before reinstalling")
|
|
166
|
+
else:
|
|
167
|
+
commands.append(["plugin", "install", SELECTOR, "--scope", self.scope])
|
|
168
|
+
return commands
|
|
169
|
+
if self.host == "pi":
|
|
170
|
+
return [] if registration["installed"] else [["install", str(bundle), *(["-l"] if self.scope == "project" else [])]]
|
|
171
|
+
return [] if registration["installed"] and registration["enabled"] and not changed else [
|
|
172
|
+
["plugin", "--profile", self.profile, "add", str(bundle)]]
|
|
173
|
+
|
|
174
|
+
def installed_path(self, bundle, version):
|
|
175
|
+
registration = self.registration(bundle)
|
|
176
|
+
if not registration["installed"]:
|
|
177
|
+
raise RuntimeError(self.host + " did not confirm the expected native registration")
|
|
178
|
+
if self.host == "claude":
|
|
179
|
+
plugin = registration["plugin"]
|
|
180
|
+
if not plugin.get("enabled") or plugin.get("version") != version:
|
|
181
|
+
raise RuntimeError("Claude did not activate the expected plugin version")
|
|
182
|
+
path = plugin.get("installPath")
|
|
183
|
+
if not isinstance(path, str) or not Path(path).is_dir():
|
|
184
|
+
raise RuntimeError("Claude did not return a valid installed cache path")
|
|
185
|
+
return Path(path).resolve()
|
|
186
|
+
if self.host == "dsh":
|
|
187
|
+
if not registration["enabled"]:
|
|
188
|
+
raise RuntimeError("DSH installed the dependency without enabling its bundle")
|
|
189
|
+
composition = self.run(["--profile", self.profile, "--dump-config"], quiet_error=True)
|
|
190
|
+
if not all(name in composition for name in ("bookmark-research-skill", "bookmark-research-mcp", "bookmarkResearchPaths")):
|
|
191
|
+
raise RuntimeError("DSH composition is missing the Bookmark Research Skill or MCP configuration")
|
|
192
|
+
package = self.settings.parent / "node_modules" / NAME
|
|
193
|
+
if not package.is_dir():
|
|
194
|
+
raise RuntimeError("DSH did not retain the installed bundle in its profile")
|
|
195
|
+
return package.resolve()
|
|
196
|
+
return bundle
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Persist complete host exports and delegate registration to native clients."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
from export_bundle import SOURCE_ROOT, _write_adapter, export_bundle, read_plugin_manifest
|
|
15
|
+
from host_clients import HostClient, read_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_json(path, value):
|
|
19
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent, delete=False) as stream:
|
|
20
|
+
temporary = Path(stream.name)
|
|
21
|
+
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
22
|
+
stream.write("\n")
|
|
23
|
+
stream.flush()
|
|
24
|
+
os.fsync(stream.fileno())
|
|
25
|
+
try:
|
|
26
|
+
os.replace(temporary, path)
|
|
27
|
+
finally:
|
|
28
|
+
temporary.unlink(missing_ok=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _files(root):
|
|
32
|
+
result = {}
|
|
33
|
+
for path in sorted(root.rglob("*")):
|
|
34
|
+
relative = path.relative_to(root)
|
|
35
|
+
if any(part in ("__pycache__", "node_modules", ".git") for part in relative.parts) or path.suffix in (".pyc", ".pyo"):
|
|
36
|
+
continue
|
|
37
|
+
if path.is_symlink():
|
|
38
|
+
raise ValueError("Unexpected link in managed package: " + str(relative))
|
|
39
|
+
if path.is_file():
|
|
40
|
+
result[relative.as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_files(root, expected, exact=False):
|
|
45
|
+
if exact and _files(root) != expected:
|
|
46
|
+
raise RuntimeError("Installed package differs from its verified source; preserve local edits before updating")
|
|
47
|
+
for relative, digest in expected.items():
|
|
48
|
+
path = root / relative
|
|
49
|
+
current = root
|
|
50
|
+
for part in Path(relative).parts:
|
|
51
|
+
if current.is_symlink():
|
|
52
|
+
raise RuntimeError("Unexpected link in installed package: " + relative)
|
|
53
|
+
current = current / part
|
|
54
|
+
if path.is_symlink() or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
55
|
+
raise RuntimeError("Installed package differs from its verified source: " + relative)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _source(value, ref=None):
|
|
59
|
+
# Keep Git URL normalization identical to the existing Codex installer.
|
|
60
|
+
from install import _repository
|
|
61
|
+
value = str(value)
|
|
62
|
+
if not value or any(ord(char) < 32 for char in value):
|
|
63
|
+
raise ValueError("Source must be a nonempty path or Git repository")
|
|
64
|
+
path = Path(value).expanduser()
|
|
65
|
+
if path.exists() or value.startswith(("/", "./", "../", "~")):
|
|
66
|
+
if ref:
|
|
67
|
+
raise ValueError("--ref only applies to Git; update local source files yourself")
|
|
68
|
+
root = path.resolve()
|
|
69
|
+
read_plugin_manifest(root)
|
|
70
|
+
return {"sourceType": "local", "source": str(root)}
|
|
71
|
+
if ref and (ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9_./-]+", ref)):
|
|
72
|
+
raise ValueError("Invalid Git ref")
|
|
73
|
+
return {"sourceType": "git", "source": _repository(value), "ref": ref}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def _checkout(source, timeout):
|
|
78
|
+
if source["sourceType"] == "local":
|
|
79
|
+
yield Path(source["source"]), None
|
|
80
|
+
return
|
|
81
|
+
with tempfile.TemporaryDirectory(prefix="bookmark-host-source-") as directory:
|
|
82
|
+
root = Path(directory)
|
|
83
|
+
environment = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
84
|
+
for arguments in (["init", "--quiet"], ["remote", "add", "origin", source["source"]],
|
|
85
|
+
["fetch", "--quiet", "--depth", "1", "origin", source.get("ref") or "HEAD"],
|
|
86
|
+
["checkout", "--quiet", "--detach", "FETCH_HEAD"]):
|
|
87
|
+
completed = subprocess.run(["git", "-C", str(root), *arguments], env=environment,
|
|
88
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
89
|
+
if completed.returncode:
|
|
90
|
+
raise RuntimeError("Could not prepare the registered Git source: " + completed.stderr.strip()[-2000:])
|
|
91
|
+
revision = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True,
|
|
92
|
+
text=True, encoding="utf-8", errors="replace", check=True, timeout=timeout).stdout.strip()
|
|
93
|
+
yield root, revision
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def runtime_check(root, timeout):
|
|
97
|
+
"""Check the exported CLI and stdio protocol using an empty, disposable store."""
|
|
98
|
+
requests = [
|
|
99
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
100
|
+
"protocolVersion": "2025-11-25", "capabilities": {},
|
|
101
|
+
"clientInfo": {"name": "bookmark-host-installer", "version": "1"}}},
|
|
102
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
103
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
104
|
+
]
|
|
105
|
+
with tempfile.TemporaryDirectory(prefix="bookmark-host-probe-") as directory:
|
|
106
|
+
data = Path(directory)
|
|
107
|
+
environment = dict(os.environ, PYTHONDONTWRITEBYTECODE="1",
|
|
108
|
+
BOOKMARK_RESEARCH_DATA_DIR=str(data / "data"),
|
|
109
|
+
BOOKMARK_RESEARCH_CONFIG=str(data / "settings.json"))
|
|
110
|
+
command = [sys.executable, "-B", str(root / "src/cli.py")]
|
|
111
|
+
doctor = subprocess.run([*command, "doctor"], cwd=data, env=environment, capture_output=True,
|
|
112
|
+
text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
113
|
+
if doctor.returncode or json.loads(doctor.stdout).get("fts5") is not True:
|
|
114
|
+
raise RuntimeError("Exported Python runtime requires Python 3.9+ and SQLite FTS5")
|
|
115
|
+
mcp = subprocess.run([*command, "serve"], cwd=data, env=environment, capture_output=True,
|
|
116
|
+
text=True, encoding="utf-8", errors="replace", input="".join(json.dumps(row) + "\n" for row in requests), timeout=timeout)
|
|
117
|
+
if mcp.returncode:
|
|
118
|
+
raise RuntimeError("Exported MCP runtime failed to start")
|
|
119
|
+
responses = {row.get("id"): row for row in map(json.loads, mcp.stdout.splitlines())}
|
|
120
|
+
names = sorted(row["name"] for row in responses.get(2, {}).get("result", {}).get("tools", []))
|
|
121
|
+
if "result" not in responses.get(1, {}) or not {"search_bookmarks", "search_web", "fetch_web"} <= set(names):
|
|
122
|
+
raise RuntimeError("Exported MCP runtime did not expose the expected tools")
|
|
123
|
+
if (data / "data/index.sqlite3").exists():
|
|
124
|
+
raise RuntimeError("Runtime discovery unexpectedly created a database")
|
|
125
|
+
return {"fts5": True, "mcp_tools": names, "network_checked": False,
|
|
126
|
+
"database_created": False, "host_session_checked": False}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_receipt(path, target):
|
|
130
|
+
receipt = read_json(path)
|
|
131
|
+
if receipt is None:
|
|
132
|
+
return None
|
|
133
|
+
if not isinstance(receipt, dict) or receipt.get("schema") != 1 or receipt.get("target") != target:
|
|
134
|
+
raise ValueError("Invalid or mismatched installation receipt: " + str(path))
|
|
135
|
+
files = receipt.get("files")
|
|
136
|
+
if not isinstance(files, dict) or not files:
|
|
137
|
+
raise ValueError("Installation receipt has no package checksums")
|
|
138
|
+
for relative, digest in files.items():
|
|
139
|
+
if Path(relative).is_absolute() or ".." in Path(relative).parts or not re.fullmatch(r"[a-f0-9]{64}", str(digest)):
|
|
140
|
+
raise ValueError("Invalid package checksum in installation receipt")
|
|
141
|
+
if receipt.get("source", {}).get("sourceType") not in ("git", "local"):
|
|
142
|
+
raise ValueError("Invalid source in installation receipt")
|
|
143
|
+
return receipt
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _verify(client, bundle, receipt):
|
|
147
|
+
_check_files(bundle, receipt["files"], exact=True)
|
|
148
|
+
root = client.installed_path(bundle, receipt["version"])
|
|
149
|
+
_check_files(root, receipt["files"])
|
|
150
|
+
return {"host": client.host, "installed": True, "verified": True,
|
|
151
|
+
"version": receipt["version"], "release_version": receipt["release_version"],
|
|
152
|
+
"installed_path": str(root), "source": receipt["source"], "target": client.target,
|
|
153
|
+
"runtime": runtime_check(root, client.timeout)}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _manage(action, client, base, source, ref, dry_run):
|
|
157
|
+
bundle, receipt_path = base / "bundle", base / "receipt.json"
|
|
158
|
+
receipt = _load_receipt(receipt_path, client.target)
|
|
159
|
+
if action == "verify":
|
|
160
|
+
if not receipt:
|
|
161
|
+
raise ValueError("No managed installation for this host and scope/profile; run install first")
|
|
162
|
+
result = _verify(client, bundle, receipt)
|
|
163
|
+
if receipt.get("state") != "verified":
|
|
164
|
+
receipt["state"] = "verified"
|
|
165
|
+
_write_json(receipt_path, receipt)
|
|
166
|
+
return result
|
|
167
|
+
if receipt:
|
|
168
|
+
if source is not None:
|
|
169
|
+
requested = _source(source, ref)
|
|
170
|
+
old = receipt["source"]
|
|
171
|
+
if (requested["sourceType"], requested["source"]) != (old["sourceType"], old["source"]) or (ref is not None and ref != old.get("ref")):
|
|
172
|
+
raise ValueError("Existing installation retains its source and ref; choose a separate installation directory for another source")
|
|
173
|
+
elif ref is not None and ref != receipt["source"].get("ref"):
|
|
174
|
+
raise ValueError("Existing installation retains its registered ref")
|
|
175
|
+
requested = receipt["source"]
|
|
176
|
+
if bundle.exists():
|
|
177
|
+
_check_files(bundle, receipt["files"], exact=True)
|
|
178
|
+
else:
|
|
179
|
+
if action == "update":
|
|
180
|
+
raise ValueError("No managed installation for this target; run install first with the intended scope/profile")
|
|
181
|
+
if bundle.exists() or bundle.is_symlink():
|
|
182
|
+
raise ValueError("Refusing an existing package without an installation receipt")
|
|
183
|
+
requested = _source(source if source is not None else SOURCE_ROOT, ref)
|
|
184
|
+
if requested["sourceType"] == "local" and (Path(requested["source"]) == base or Path(requested["source"]) in base.parents):
|
|
185
|
+
raise ValueError("The managed installation directory must be outside the source checkout")
|
|
186
|
+
registration = client.registration(bundle)
|
|
187
|
+
with _checkout(requested, client.timeout) as (root, revision), tempfile.TemporaryDirectory(
|
|
188
|
+
prefix=".bookmark-stage-", dir=base if not dry_run else None) as temporary:
|
|
189
|
+
stage = Path(temporary) / "bundle"
|
|
190
|
+
# Windows has no python3 alias by default; pin the interpreter running the installer.
|
|
191
|
+
python = sys.executable if os.name == "nt" else "python3"
|
|
192
|
+
export_bundle(client.host, stage, root, python)
|
|
193
|
+
manifest = read_plugin_manifest(root)
|
|
194
|
+
# Render path-dependent manual instructions for the persistent location.
|
|
195
|
+
_write_adapter(stage, client.host, bundle, manifest, python)
|
|
196
|
+
content = _files(stage)
|
|
197
|
+
digest = hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest()
|
|
198
|
+
version = manifest["version"]
|
|
199
|
+
if client.host == "claude":
|
|
200
|
+
# Claude caches explicit versions. Content changes on main must get a
|
|
201
|
+
# new native cache entry without modifying the source release version.
|
|
202
|
+
version = version.split("+", 1)[0] + "+source." + digest[:16]
|
|
203
|
+
path = stage / ".claude-plugin/plugin.json"
|
|
204
|
+
metadata = read_json(path)
|
|
205
|
+
metadata["version"] = version
|
|
206
|
+
_write_json(path, metadata)
|
|
207
|
+
for relative in (".claude-plugin/plugin.json", ".claude-plugin/marketplace.json"):
|
|
208
|
+
client.run(["plugin", "validate", "--strict", str(stage / relative)])
|
|
209
|
+
files = _files(stage)
|
|
210
|
+
runtime_check(stage, client.timeout)
|
|
211
|
+
changed = not receipt or files != receipt["files"]
|
|
212
|
+
commands = client.commands(bundle, registration, changed or (receipt and receipt.get("state") != "verified"))
|
|
213
|
+
plan = {"host": client.host, "action": action, "source": requested, "target": client.target,
|
|
214
|
+
"bundle_path": str(bundle), "receipt_path": str(receipt_path), "dry_run": dry_run,
|
|
215
|
+
"version": version, "release_version": manifest["version"], "revision": revision,
|
|
216
|
+
"commands": [[client.binary, *arguments] for arguments in commands], "changed": changed}
|
|
217
|
+
if dry_run:
|
|
218
|
+
return plan
|
|
219
|
+
new_receipt = {"schema": 1, "state": "pending", "target": client.target, "source": requested,
|
|
220
|
+
"revision": revision, "version": version, "release_version": manifest["version"], "files": files}
|
|
221
|
+
previous = Path(temporary) / "previous"
|
|
222
|
+
if bundle.exists():
|
|
223
|
+
bundle.rename(previous)
|
|
224
|
+
try:
|
|
225
|
+
stage.rename(bundle)
|
|
226
|
+
_write_json(receipt_path, new_receipt)
|
|
227
|
+
for arguments in commands:
|
|
228
|
+
client.run(arguments)
|
|
229
|
+
checked = _verify(client, bundle, new_receipt)
|
|
230
|
+
new_receipt["state"] = "verified"
|
|
231
|
+
_write_json(receipt_path, new_receipt)
|
|
232
|
+
except BaseException:
|
|
233
|
+
if previous.exists():
|
|
234
|
+
if bundle.exists():
|
|
235
|
+
bundle.rename(Path(temporary) / "failed")
|
|
236
|
+
previous.rename(bundle)
|
|
237
|
+
failed_receipt = dict(receipt)
|
|
238
|
+
else:
|
|
239
|
+
# A native command may already reference this path. Keep it for
|
|
240
|
+
# recovery instead of leaving a newly registered package dangling.
|
|
241
|
+
failed_receipt = new_receipt
|
|
242
|
+
failed_receipt["state"] = "needs_verification"
|
|
243
|
+
_write_json(receipt_path, failed_receipt)
|
|
244
|
+
raise
|
|
245
|
+
return {**plan, **checked, "next_step": "Restart " + client.host + " to load the installed Skill and tools."}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def manage_host(action, host, binary=None, timeout=60, source=None, ref=None, dry_run=False,
|
|
249
|
+
scope=None, project=None, profile=None, install_dir=None):
|
|
250
|
+
if host not in ("claude", "pi", "dsh"):
|
|
251
|
+
raise ValueError("Unsupported host: " + host)
|
|
252
|
+
client = HostClient(host, binary or host, timeout, scope, project, profile)
|
|
253
|
+
client.require()
|
|
254
|
+
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))).expanduser()
|
|
255
|
+
if not data_home.is_absolute():
|
|
256
|
+
data_home = Path.home() / ".local/share"
|
|
257
|
+
home = Path(install_dir or os.environ.get("BOOKMARK_RESEARCH_INSTALL_DIR", str(data_home / "bookmark-research/installations"))).expanduser().resolve()
|
|
258
|
+
key = hashlib.sha256(json.dumps(client.target, sort_keys=True).encode()).hexdigest()[:16]
|
|
259
|
+
base = home / (host + "-" + key)
|
|
260
|
+
if base.is_symlink():
|
|
261
|
+
raise ValueError("Refusing a symlink installation directory")
|
|
262
|
+
if dry_run or (action == "verify" and not base.exists()):
|
|
263
|
+
return _manage(action, client, base, source, ref, dry_run)
|
|
264
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
# The OS releases these locks on a crash; they stop two installers from
|
|
266
|
+
# replacing the same persistent package.
|
|
267
|
+
with (base / "install.lock").open("a") as lock:
|
|
268
|
+
try:
|
|
269
|
+
if os.name == "nt":
|
|
270
|
+
import msvcrt
|
|
271
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
272
|
+
else:
|
|
273
|
+
import fcntl
|
|
274
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
275
|
+
except OSError as error:
|
|
276
|
+
raise RuntimeError("Another installer is already changing this target") from error
|
|
277
|
+
return _manage(action, client, base, source, ref, dry_run)
|