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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. 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)