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,143 @@
1
+ #!/usr/bin/env python3
2
+ """Build a source distribution ZIP with the native plugin and host exporter."""
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ import tempfile
11
+ import zipfile
12
+
13
+ from export_bundle import EXCLUDED_DIRECTORIES, SOURCE_ROOT, _copy_plan, read_plugin_manifest
14
+ from host_assets import HOST_SOURCE_FILES
15
+
16
+
17
+ EXTRA_FILES = (
18
+ ".codex-plugin/plugin.json",
19
+ ".agents/plugins/marketplace.json",
20
+ "LICENSE",
21
+ "docs/installation.md",
22
+ "docs/installation.en.md",
23
+ "docs/bookmark-research-architecture.md",
24
+ "docs/install-design-0.2.0.md",
25
+ "docs/mcp-aggregation-0.2.0.md",
26
+ "docs/provider-smoke-0.2.0.json",
27
+ "docs/research-0.2.0.md",
28
+ "docs/validation-0.2.0.md",
29
+ "docs/validation-0.4.0.md",
30
+ "README.md",
31
+ "README.zh.md",
32
+ "install.sh",
33
+ "docs/harness-compatibility.md",
34
+ "docs/harness-sources.json",
35
+ "docs/host-validation.json",
36
+ "docs/wiki-quality.md",
37
+ "scripts/export_bundle.py",
38
+ "scripts/build_zip.py",
39
+ "scripts/install.py",
40
+ "scripts/host_install.py",
41
+ "scripts/host_clients.py",
42
+ "scripts/host_assets.py",
43
+ "scripts/launcher.py",
44
+ "bin/bookmark-research.js",
45
+ "package.json",
46
+ "docs/details.md",
47
+ "docs/details.en.md",
48
+ "skills/bookmark-research/agents/openai.yaml",
49
+ ) + HOST_SOURCE_FILES
50
+
51
+
52
+ def _read_file(path, root):
53
+ """Read a selected source file without following links outside the package."""
54
+ relative = path.relative_to(root)
55
+ current = root
56
+ for part in relative.parts:
57
+ current = current / part
58
+ if current.is_symlink():
59
+ raise ValueError("Source symlinks are not packaged: " + str(relative))
60
+ if not path.is_file():
61
+ raise ValueError("Required distribution file is missing: " + str(relative))
62
+ return path.read_bytes()
63
+
64
+
65
+ def _test_files(source):
66
+ """Include the curated test tree only for an explicit verification pack."""
67
+ root = source / "tests"
68
+ if root.is_symlink() or not root.is_dir():
69
+ raise ValueError("The verification pack requires a local tests directory")
70
+ selected = {Path("scripts/verify_fixture.py"), Path("scripts/verify_quality.py")}
71
+ for path in sorted(root.rglob("*")):
72
+ relative = path.relative_to(source)
73
+ if any(part.startswith(".") or part.casefold() in EXCLUDED_DIRECTORIES - {"tests", "test"}
74
+ for part in relative.parts):
75
+ continue
76
+ if path.is_symlink():
77
+ raise ValueError("Source symlinks are not packaged: " + relative.as_posix())
78
+ if path.is_file() and (path.suffix == ".py" or path.name.endswith(".test.js") or
79
+ relative.parts[:2] == ("tests", "fixtures")
80
+ and path.suffix in (".json", ".canvas", ".md", ".txt")):
81
+ selected.add(relative)
82
+ if not any(path.name.startswith("test_") and path.suffix == ".py" for path in selected):
83
+ raise ValueError("The verification pack requires Python tests")
84
+ return selected
85
+
86
+
87
+ def build_zip(output, source_root=SOURCE_ROOT, include_tests=False):
88
+ source = Path(source_root).resolve()
89
+ manifest = read_plugin_manifest(source)
90
+ selected = set(_copy_plan(source, include_codex_metadata=True)) | {Path(name) for name in EXTRA_FILES}
91
+ if include_tests:
92
+ selected.update(_test_files(source))
93
+ payload = {path.as_posix(): _read_file(source / path, source)
94
+ for path in sorted(selected)}
95
+ checksums = "".join(hashlib.sha256(data).hexdigest() + " " + name + "\n"
96
+ for name, data in sorted(payload.items()))
97
+ payload["MANIFEST.sha256"] = checksums.encode("utf-8")
98
+
99
+ output = Path(output).expanduser()
100
+ if output.exists() or output.is_symlink():
101
+ raise ValueError("Refusing existing output: " + str(output))
102
+ if output.suffix.lower() != ".zip":
103
+ raise ValueError("Output must have a .zip extension")
104
+ output = output.resolve()
105
+ if output == source or (source in output.parents and source / "dist" not in output.parents):
106
+ raise ValueError("ZIP output inside the repository must be under dist/")
107
+ output.parent.mkdir(parents=True, exist_ok=True)
108
+ archive_root = ("bookmark-research-test-pack-" if include_tests else "bookmark-research-") + manifest["version"]
109
+ with tempfile.TemporaryDirectory(prefix=".bookmark-research-zip-", dir=output.parent) as temporary:
110
+ staged = Path(temporary) / "distribution.zip"
111
+ with zipfile.ZipFile(staged, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
112
+ for relative, data in sorted(payload.items()):
113
+ # Fixed timestamps, permissions, order and compression make a
114
+ # source snapshot reproducible with the same Python/zlib.
115
+ entry = zipfile.ZipInfo(archive_root + "/" + relative, date_time=(1980, 1, 1, 0, 0, 0))
116
+ entry.create_system = 3
117
+ entry.external_attr = 0o100644 << 16
118
+ entry.compress_type = zipfile.ZIP_DEFLATED
119
+ archive.writestr(entry, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
120
+ # A same-filesystem hard link publishes only a completed ZIP and cannot
121
+ # replace an existing path, even if another writer wins the race.
122
+ os.link(staged, output)
123
+ return {"output": str(output), "version": manifest["version"], "archive_root": archive_root, "files": len(payload),
124
+ "bytes": output.stat().st_size,
125
+ "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), "includes_tests": include_tests, "installed": False}
126
+
127
+
128
+ def main(argv=None):
129
+ parser = argparse.ArgumentParser(description=__doc__)
130
+ parser.add_argument("--output", required=True, help="New ZIP path under dist/ or outside the repository")
131
+ parser.add_argument("--include-tests", action="store_true", help="Include tests, synthetic fixtures, and the offline verifier")
132
+ args = parser.parse_args(argv)
133
+ try:
134
+ result = build_zip(args.output, include_tests=args.include_tests)
135
+ except (OSError, ValueError, zipfile.BadZipFile) as error:
136
+ print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
137
+ return 1
138
+ print(json.dumps(result, ensure_ascii=False, indent=2))
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env python3
2
+ """Export shared runtime files into a selected host format, without installing it."""
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from host_assets import export_host_assets
14
+
15
+
16
+ SOURCE_ROOT = Path(__file__).resolve().parents[1]
17
+ FORMATS = ("codex", "agent-plugin", "claude", "pi", "dsh")
18
+ NAME = "bookmark-research"
19
+ SCHEMA_ROOT = "https://agent-plugins.org/schemas/1.0.0/"
20
+ SHARED_ROOTS = ("src", "config", "skills")
21
+ SHARED_DOCUMENTS = (
22
+ "docs/wiki-quality.md", "docs/wiki-quality.zh.md",
23
+ "docs/user-guide.md", "docs/user-guide.en.md",
24
+ "docs/instructions.md", "docs/instructions.en.md",
25
+ "docs/prompt-reference.md", "docs/prompt-reference.en.md",
26
+ )
27
+ REQUIRED_FILES = (
28
+ "LICENSE",
29
+ "src/archive.py",
30
+ "src/bookmark_index.py",
31
+ "src/cli.py",
32
+ "src/credentials.py",
33
+ "src/onboarding.py",
34
+ "src/readiness.py",
35
+ "src/mcp_server.py",
36
+ "src/remote_mcp.py",
37
+ "src/provider_adapters.py",
38
+ "src/research.py",
39
+ "src/research_coverage.py",
40
+ "src/research_services.py",
41
+ "src/service_http.py",
42
+ "src/routing.py",
43
+ "src/wiki.py",
44
+ "src/evaluation.py",
45
+ "src/search_results.py",
46
+ "src/settings.py",
47
+ "src/source_inputs.py",
48
+ "src/source_manager.py",
49
+ "src/source_watcher.py",
50
+ "src/web_search.py",
51
+ "config/providers.json",
52
+ "skills/bookmark-research/SKILL.md",
53
+ ) + SHARED_DOCUMENTS
54
+ EXCLUDED_DIRECTORIES = {
55
+ "__pycache__", "node_modules", "tests", "test", "build", "dist", "target",
56
+ "cache", "caches", "coverage", "htmlcov", "venv",
57
+ "data", "knowledge", "reports", "archives",
58
+ }
59
+ EXCLUDED_SUFFIXES = {".pyc", ".pyo", ".log", ".tmp", ".swp", ".swo", ".canvas"}
60
+ DATABASE_NAME = re.compile(r"\.(?:db|db3|sqlite|sqlite3|s3db)(?:$|[.-])", re.IGNORECASE)
61
+ VERSION_PATTERN = re.compile(
62
+ r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
63
+ r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
64
+ r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?"
65
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?")
66
+
67
+
68
+ def read_plugin_manifest(source_root=SOURCE_ROOT):
69
+ """Use the native manifest as the version source for every distribution."""
70
+ source_root = Path(source_root)
71
+ path = source_root / ".codex-plugin/plugin.json"
72
+ if path.is_symlink() or path.parent.is_symlink():
73
+ raise ValueError("Source symlinks are not exported: .codex-plugin/plugin.json")
74
+ manifest = json.loads(path.read_text(encoding="utf-8"))
75
+ if not isinstance(manifest, dict) or manifest.get("name") != NAME:
76
+ raise ValueError("Expected the bookmark-research native plugin manifest")
77
+ if not isinstance(manifest.get("description"), str) or not manifest["description"].strip():
78
+ raise ValueError("The native plugin description must be nonempty")
79
+ version = manifest.get("version")
80
+ if not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version):
81
+ raise ValueError("The native plugin version must be a semantic version")
82
+ return manifest
83
+
84
+
85
+ def _copy_plan(source_root, include_codex_metadata=False):
86
+ """Enumerate only shared roots; reject links rather than following external files."""
87
+ files = []
88
+
89
+ def visit(path):
90
+ relative = path.relative_to(source_root)
91
+ if path.name.startswith(".") or path.name.casefold() in EXCLUDED_DIRECTORIES:
92
+ return
93
+ # Codex presentation metadata is not needed by any of these exports.
94
+ codex_metadata = relative.parts[0] == "skills" and relative.parts[-2:] == ("agents", "openai.yaml")
95
+ if codex_metadata and not include_codex_metadata:
96
+ return
97
+ if DATABASE_NAME.search(path.name) or path.suffix.casefold() in EXCLUDED_SUFFIXES:
98
+ return
99
+ if path.is_symlink():
100
+ raise ValueError("Source symlinks are not exported: " + str(relative))
101
+ if path.is_dir():
102
+ # Runtime configuration has one public registry. Do not copy local
103
+ # preferences or credential JSON merely because it is under config/.
104
+ children = [path / "providers.json"] if relative.as_posix() == "config" else sorted(path.iterdir())
105
+ for child in children:
106
+ visit(child)
107
+ elif path.is_file():
108
+ if relative.parts[0] == "src" and path.suffix != ".py":
109
+ return
110
+ # Skills contain only Markdown references and optional native UI
111
+ # metadata. In particular, a user-provided JSON canvas is not code.
112
+ if relative.parts[0] == "skills" and path.suffix != ".md" and not codex_metadata:
113
+ return
114
+ with path.open("rb") as stream:
115
+ if stream.read(16) == b"SQLite format 3\x00":
116
+ return
117
+ files.append(relative)
118
+ else:
119
+ raise ValueError("Unsupported or missing source entry: " + str(relative))
120
+
121
+ for root in SHARED_ROOTS:
122
+ visit(source_root / root)
123
+ for relative in SHARED_DOCUMENTS:
124
+ visit(source_root / relative)
125
+ visit(source_root / "LICENSE")
126
+ present = {path.as_posix() for path in files}
127
+ missing = set(REQUIRED_FILES) - present
128
+ if missing:
129
+ raise ValueError("Required shared files are missing: " + ", ".join(sorted(missing)))
130
+ return files
131
+
132
+
133
+ def _check_destination(output):
134
+ if output.is_symlink():
135
+ raise ValueError("Output must not be a symlink: " + str(output))
136
+ if output.exists():
137
+ if not output.is_dir():
138
+ raise ValueError("Output must be a directory: " + str(output))
139
+ if next(output.iterdir(), None) is not None:
140
+ raise ValueError("Refusing nonempty output directory: " + str(output))
141
+
142
+
143
+ def _write_json(root, relative, value):
144
+ path = root / relative
145
+ path.parent.mkdir(parents=True, exist_ok=True)
146
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
147
+
148
+
149
+ def _readme(format_name, output):
150
+ introduction = """# Bookmark Research export
151
+
152
+ [English user guide](docs/user-guide.en.md) · [中文使用指南](docs/user-guide.md)
153
+
154
+ The execution Skill and method references are in English, with complete Chinese
155
+ reading copies. See the [instruction index](docs/instructions.en.md) or
156
+ [中文指令索引](docs/instructions.md) for both versions and the host prompts.
157
+
158
+ This directory includes the shared Skill, Python CLI, stdio MCP server, and public
159
+ provider configuration. It requires Python 3.9+ and the standard library;
160
+ `doctor` checks whether the local SQLite build supports FTS5.
161
+ The repository's [license](LICENSE) is included with the source files.
162
+
163
+ Supply your own Bookmark Canvas package through `sync`. A fresh data directory
164
+ has no registered sources; existing indexes remain part of your local data.
165
+
166
+ Run these commands from this directory to check the runtime and provider registry:
167
+
168
+ ```sh
169
+ python3 src/cli.py doctor
170
+ python3 src/cli.py providers
171
+ ```
172
+
173
+ These checks run offline and do not create a persistent database. See the
174
+ [shared Skill](skills/bookmark-research/SKILL.md) for query and research workflows.
175
+ Exporting generates files; complete the client setup below to load them.
176
+
177
+ The database location follows the CLI rules: explicit `--db` takes precedence,
178
+ then `BOOKMARK_RESEARCH_DATA_DIR/index.sqlite3`, then
179
+ `${XDG_DATA_HOME:-~/.local/share}/bookmark-research/index.sqlite3`.
180
+ To share an index across clients, pass the same `BOOKMARK_RESEARCH_DATA_DIR` to
181
+ each MCP / CLI process, or use the same `--db` path. Clients may filter inherited
182
+ environment variables, so check what each client forwards to its child process.
183
+
184
+ Use `config show` and `config set` to inspect or change settings. The configuration
185
+ path is selected by `--config`, `BOOKMARK_RESEARCH_CONFIG`, or
186
+ `${XDG_CONFIG_HOME:-~/.config}/bookmark-research/settings.json`, in that order.
187
+ Page reads archive actual responses, recognized text, and source records under
188
+ `knowledge/` in the data directory by default. You can change this location or
189
+ disable archiving. To share settings, forward the same configuration path through
190
+ each client, including `BOOKMARK_RESEARCH_CONFIG` or `XDG_CONFIG_HOME` as needed.
191
+ See the [user guide and settings](docs/user-guide.en.md), or the detailed
192
+ [settings reference](skills/bookmark-research/references/settings-and-archive.md).
193
+
194
+ Keep databases, user settings, and page archives outside this package. Exports
195
+ exclude existing indexes, caches, tests, and build artifacts. They do not read
196
+ API key values from the environment; supply provider credentials at runtime.
197
+
198
+ Run `python3 src/cli.py setup` to guide preferences, hidden credential entry and
199
+ service checks. Use `--host codex|claude_code|pi|dsh` for host-specific MCP/login
200
+ guidance. Agents can use `setup --non-interactive --input FILE --skip-checks`.
201
+ Keys are saved separately in a private local `credentials.json`, never in this
202
+ package. Environment variables override saved keys. Before each new web research
203
+ question, use `readiness` (MCP `research_readiness`); it respects cached/always/manual
204
+ preferences. `--test-retrieval` opts into sample search/read quota. No professional
205
+ research job is created by checks. Local queries require neither keys nor checks.
206
+
207
+ """
208
+ if format_name == "codex":
209
+ instructions = """## Codex
210
+
211
+ This clean bundle contains the native `.codex-plugin/plugin.json` and a local
212
+ `.agents/plugins/marketplace.json`. Keep this directory at a stable path.
213
+
214
+ ```sh
215
+ codex plugin marketplace add '/absolute/path/to/this-bundle'
216
+ codex plugin add bookmark-research@bookmark-research
217
+ ```
218
+
219
+ If that marketplace name already points elsewhere, choose the source you intend
220
+ to keep before installing. Start a new Codex thread after installation or update.
221
+ Do not put user data in the installed cache.
222
+
223
+ For a multi-group study, follow `hosts/codex/delegate.md` with the native
224
+ subagent tools. `python3 hosts/codex/prepare.py --research-id RID` reads every
225
+ inventory page and emits complete ID groups; it does not start agents.
226
+
227
+ Reference: https://developers.openai.com/codex/cli/reference#codex-plugin
228
+ """
229
+ elif format_name == "agent-plugin":
230
+ instructions = """## Agent Plugins 1.0.0
231
+
232
+ The entry files are root `plugin.json` and `mcp.json`, with the Skill under
233
+ `skills/bookmark-research/`. The client must implement this specification and
234
+ expand `${PLUGIN_ROOT}` in MCP arguments to this package's absolute path.
235
+ The command is `python3`; database placement follows the CLI rules above.
236
+ This export does not force the use of `${PLUGIN_DATA}`.
237
+
238
+ This is a separate standard format. Export checks do not establish compatibility
239
+ with an untested client or make it equivalent to Codex's native plugin format.
240
+
241
+ Specification: https://agent-plugins.org/specification
242
+ """
243
+ elif format_name == "claude":
244
+ instructions = """## Claude Code
245
+
246
+ The entry files are `.claude-plugin/plugin.json` and root `.mcp.json`. MCP
247
+ arguments use `${CLAUDE_PLUGIN_ROOT}`. A local marketplace manifest is included.
248
+ With Claude Code installed, register this stable export persistently:
249
+
250
+ ```sh
251
+ claude plugin marketplace add /absolute/path/to/this-bundle
252
+ claude plugin install bookmark-research@bookmark-research --scope user
253
+ ```
254
+
255
+ Reuse existing registrations instead of replacing a different source. For session
256
+ development, load the export using its actual absolute path:
257
+
258
+ ```sh
259
+ claude --plugin-dir /absolute/path/to/this-bundle
260
+ ```
261
+
262
+ The plugin includes `workflows/bookmark-research.js`, a native Dynamic Workflow.
263
+ After creating a research session, explicitly invoke
264
+ `/bookmark-research:bookmark-research` with structured `research_id` and a unique
265
+ `run_key` (for example, `review-1`). It reads groups, independently verifies them,
266
+ reads all coverage differences, and saves a report and full analysis attachment.
267
+
268
+ Dynamic Workflows require Claude Code 2.1.154+ and enabled workflow access. Runs
269
+ are managed through `/workflows`; pause/resume is limited to the same session
270
+ and some completed children can replay. `/deep-research` is a separate built-in
271
+ command, explicit-only since 2.1.218, and does not guarantee bookmark inventory
272
+ coverage. Injecting `ultracode` into a Skill or `-p` prompt does not invoke it.
273
+ Client version/manifest checks do not establish a paid research run succeeded.
274
+
275
+ Reference: https://code.claude.com/docs/en/plugins-reference
276
+ """
277
+ elif format_name == "pi":
278
+ instructions = """## Pi Skill and saved workflow package
279
+
280
+ `package.json` declares `pi.skills`. The shared Skill calls the bundled Python
281
+ CLI. The saved workflow requires already-installed `pi-subagents` 0.43.0+ and
282
+ `pi-subagents-workflows` in the same parent session, Pi 0.83+, and Node 22.19+.
283
+ These are extension capabilities; exporting does not install them.
284
+
285
+ Load the Skill for one session, replacing the path with your export location:
286
+
287
+ ```sh
288
+ pi --skill /absolute/path/to/this-bundle/skills/bookmark-research/SKILL.md
289
+ ```
290
+
291
+ Use `/skill:bookmark-research` in the session to load its workflow. For persistent
292
+ registration of the Skill, follow Pi's package instructions and run
293
+ `pi install /absolute/path/to/this-bundle`. That command changes Pi settings;
294
+ the exporter itself does not run it.
295
+
296
+ Register the saved workflow in a trusted project without modifying Pi settings:
297
+
298
+ ```sh
299
+ python3 hosts/pi/register-workflow.py --project /absolute/path/to/project
300
+ ```
301
+
302
+ This copies `workflow.json` and `script.js` into that project's
303
+ `.pi/subagent-workflows/bookmark-research/` and binds the stable bundle's shared
304
+ tool bridge. Existing different definitions are preserved and reported as a
305
+ conflict. Start Pi in that project. Use
306
+ `pi_subagent_workflow({action:"run",name:"bookmark-research",args:{research_id:"RID",run_key:"review-1"}})`.
307
+ Runs are detached; wait through pi-subagents status/artifacts, inspecting every
308
+ failed child. The registry adds no cross-session journal replay. The `delegate`
309
+ agent must have Bash access to the bundled Python bridge, or native research MCP
310
+ tools. The bridge calls the same stdio server; it is not a Pi core MCP feature.
311
+ Actual loading in Pi still needs client verification.
312
+
313
+ Reference: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md
314
+ """
315
+ else:
316
+ patch_path = shlex.quote(str(output / "cordis.patch.yml"))
317
+ skill_root = str(output / "skills")
318
+ instructions = """## DeepSeek Harness bundle
319
+
320
+ `package.json` declares `dsh.bundle`, using `bundle.patch.yml` and a module that
321
+ resolves its Python runtime and Skill from its installed location. Select an
322
+ existing profile with the Skill registry and official MCP client available:
323
+
324
+ ```sh
325
+ dsh plugin --profile web add /absolute/path/to/this-bundle
326
+ dsh --profile web --dump-config
327
+ ```
328
+
329
+ The bundle connects both Skill discovery and stdio MCP. It explicitly forwards
330
+ the plugin's data/config/credential paths and provider-key environment variables
331
+ through the MCP client's filtered child environment. No values are baked into
332
+ the export. After moving the source export, reinstall it from the new location.
333
+
334
+ The legacy `cordis.patch.yml` connects MCP only and contains absolute local paths.
335
+ Regenerate it after moving the export. For this manual adapter, inspect the merged
336
+ configuration with:
337
+
338
+ ```sh
339
+ dsh --profile web --patch %s --dump-config
340
+ ```
341
+
342
+ Start the client with `dsh web --patch /absolute/path/to/this-bundle/cordis.patch.yml`,
343
+ using your actual patch path. These integration commands still need client verification.
344
+
345
+ The MCP patch does not configure Skill discovery. To use the shared Skill, point
346
+ the existing Skill provider's `customSkillDirs` at `%s`, or use the documented
347
+ DSH Skill directories. DSH is in developer preview, so setup options may change.
348
+
349
+ The selected profile must separately expose `workflow` through its configured
350
+ workflow service, worker-thread engine and tool. Generate the complete call:
351
+
352
+ ```sh
353
+ python3 hosts/dsh/workflow-call.py --research-id RID --run-key review-1
354
+ ```
355
+
356
+ Pass that JSON object directly to the visible workflow tool. Its fields are
357
+ separate `meta`, plain-JavaScript `script`, and object-valued `args`; there is no
358
+ `export const meta` inside the script. DSH waits for the entire workflow, returns
359
+ `{runId,agentsStarted,result}`, and cancellation is an error. Rendered JSON may
360
+ be truncated; the reporting child saves the complete analysis object through
361
+ `research_record` and returns its real `result_path`. There is no background
362
+ start/poll API or automatically generated full-result retrieval handle.
363
+
364
+ Reference: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md
365
+ """ % (patch_path, skill_root)
366
+ return introduction + instructions
367
+
368
+
369
+ def _write_adapter(stage, format_name, output, manifest, python="python3"):
370
+ metadata = {"name": NAME, "version": manifest["version"], "description": manifest["description"]}
371
+ if format_name == "codex":
372
+ _write_json(stage, ".codex-plugin/plugin.json", manifest)
373
+ _write_json(stage, ".agents/plugins/marketplace.json", {
374
+ "name": NAME, "interface": {"displayName": "Bookmark Research"},
375
+ "plugins": [{"name": NAME, "source": {"source": "local", "path": "./"},
376
+ "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
377
+ "category": "Productivity"}],
378
+ })
379
+ elif format_name == "agent-plugin":
380
+ _write_json(stage, "plugin.json", {"$schema": SCHEMA_ROOT + "plugin.schema.json", **metadata})
381
+ _write_json(stage, "mcp.json", {
382
+ "$schema": SCHEMA_ROOT + "mcp.schema.json",
383
+ "mcpServers": {NAME: {"type": "stdio", "command": python,
384
+ "args": ["${PLUGIN_ROOT}/src/cli.py", "serve"]}},
385
+ })
386
+ elif format_name == "claude":
387
+ claude_metadata = {**metadata, "workflows": "./workflows"}
388
+ if manifest.get("author"):
389
+ claude_metadata["author"] = manifest["author"]
390
+ _write_json(stage, ".claude-plugin/plugin.json", claude_metadata)
391
+ _write_json(stage, ".claude-plugin/marketplace.json", {
392
+ "name": NAME, "description": manifest["description"],
393
+ "owner": {"name": "Bookmark Research"},
394
+ "plugins": [{"name": NAME, "source": "./", "description": manifest["description"]}],
395
+ })
396
+ _write_json(stage, ".mcp.json", {"mcpServers": {NAME: {
397
+ "type": "stdio", "command": python,
398
+ "args": ["${CLAUDE_PLUGIN_ROOT}/src/cli.py", "serve"],
399
+ }}})
400
+ elif format_name == "pi":
401
+ _write_json(stage, "package.json", {**metadata, "keywords": ["pi-package"],
402
+ "pi": {"skills": ["./skills"]}})
403
+ else:
404
+ _write_json(stage, "package.json", {
405
+ **metadata, "type": "module", "main": "./hosts/dsh/plugin.js",
406
+ "exports": "./hosts/dsh/plugin.js",
407
+ "files": ["src", "config", "skills", "hosts", "workflows", "docs", "bundle.patch.yml", "LICENSE"],
408
+ "dsh": {"bundle": {"patch": "./bundle.patch.yml"}},
409
+ })
410
+ forwarded = manifest["mcpServers"][NAME]["env_vars"]
411
+ environment = json.dumps("Object.fromEntries(" + json.dumps(forwarded) +
412
+ ".filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]))")
413
+ (stage / "bundle.patch.yml").write_text("""- insert:
414
+ - id: bookmark-research-skill
415
+ name: bookmark-research
416
+ - id: bookmark-research-mcp
417
+ name: '@deepseek-ai/dsh-mcp-client'
418
+ inject: [bookmarkResearchPaths]
419
+ config:
420
+ serverName: bookmark-research
421
+ transport: stdio
422
+ command: %s
423
+ args: !!js "[ctx.bookmarkResearchPaths.cli, 'serve']"
424
+ cwd: !!js ctx.bookmarkResearchPaths.root
425
+ env: !!js %s
426
+ failOnStartupError: true
427
+ """ % (json.dumps(python), environment), encoding="utf-8")
428
+ # JSON strings are valid YAML scalars, including paths with spaces/quotes.
429
+ cli_path = json.dumps(str(output / "src/cli.py"), ensure_ascii=False)
430
+ patch = """- insert:
431
+ - id: bookmark-research-mcp
432
+ name: '@deepseek-ai/dsh-mcp-client'
433
+ config:
434
+ serverName: bookmark-research
435
+ transport: stdio
436
+ command: %s
437
+ args:
438
+ - %s
439
+ - serve
440
+ env: !!js %s
441
+ failOnStartupError: true
442
+ """ % (json.dumps(python), cli_path, environment)
443
+ (stage / "cordis.patch.yml").write_text(patch, encoding="utf-8")
444
+ (stage / "README.md").write_text(_readme(format_name, output), encoding="utf-8")
445
+
446
+
447
+ def export_bundle(format_name, output, source_root=SOURCE_ROOT, python="python3"):
448
+ """Publish a staged export to a missing or empty directory; never merge files."""
449
+ if format_name not in FORMATS:
450
+ raise ValueError("Unknown export format: " + str(format_name))
451
+ source_root = Path(source_root).expanduser().resolve()
452
+ output = Path(output).expanduser()
453
+ _check_destination(output)
454
+ output = output.resolve()
455
+ if output == source_root or output in source_root.parents:
456
+ raise ValueError("Output must be separate from the source directory")
457
+ for root in SHARED_ROOTS:
458
+ shared = source_root / root
459
+ if output == shared or shared in output.parents:
460
+ raise ValueError("Output must not be inside a shared source root: " + root)
461
+ manifest = read_plugin_manifest(source_root)
462
+ files = _copy_plan(source_root, include_codex_metadata=format_name == "codex")
463
+ output.parent.mkdir(parents=True, exist_ok=True)
464
+ with tempfile.TemporaryDirectory(prefix=".bookmark-research-export-", dir=output.parent) as temporary:
465
+ stage = Path(temporary) / "bundle"
466
+ stage.mkdir()
467
+ for relative in files:
468
+ destination = stage / relative
469
+ destination.parent.mkdir(parents=True, exist_ok=True)
470
+ shutil.copy2(source_root / relative, destination)
471
+ host_files = export_host_assets(source_root, stage, format_name)
472
+ _write_adapter(stage, format_name, output, manifest, python)
473
+ _check_destination(output)
474
+ if output.exists():
475
+ output.rmdir() # Only an empty directory can be removed here.
476
+ stage.rename(output) # Renaming a directory cannot replace nonempty data.
477
+ return {"format": format_name, "version": manifest["version"], "output": str(output), "shared_files": len(files),
478
+ "host_files": host_files, "installed": False}
479
+
480
+
481
+ def main(argv=None):
482
+ parser = argparse.ArgumentParser(description=__doc__)
483
+ parser.add_argument("--format", choices=FORMATS, required=True)
484
+ parser.add_argument("--output", required=True, help="New or empty output directory")
485
+ args = parser.parse_args(argv)
486
+ try:
487
+ result = export_bundle(args.format, args.output)
488
+ except (OSError, ValueError, RuntimeError) as error:
489
+ print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
490
+ return 1
491
+ print(json.dumps(result, ensure_ascii=False, indent=2))
492
+ return 0
493
+
494
+
495
+ if __name__ == "__main__":
496
+ raise SystemExit(main())