xrefkit 0.3.0__py3-none-any.whl

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 (65) hide show
  1. xrefkit/__init__.py +5 -0
  2. xrefkit/__main__.py +5 -0
  3. xrefkit/catalog_cli.py +57 -0
  4. xrefkit/cli.py +71 -0
  5. xrefkit/contracts.py +297 -0
  6. xrefkit/ctx.py +160 -0
  7. xrefkit/dashboard.py +1220 -0
  8. xrefkit/discovery.py +85 -0
  9. xrefkit/gate.py +428 -0
  10. xrefkit/goalstate.py +555 -0
  11. xrefkit/hashing.py +18 -0
  12. xrefkit/import_skill.py +469 -0
  13. xrefkit/instance.py +133 -0
  14. xrefkit/loaders.py +77 -0
  15. xrefkit/mcp/__init__.py +26 -0
  16. xrefkit/mcp/audit.py +168 -0
  17. xrefkit/mcp/bootstrap.py +337 -0
  18. xrefkit/mcp/catalog.py +2638 -0
  19. xrefkit/mcp/cli.py +173 -0
  20. xrefkit/mcp/client_cache.py +356 -0
  21. xrefkit/mcp/context_registry.py +277 -0
  22. xrefkit/mcp/contracts.py +243 -0
  23. xrefkit/mcp/dist.py +234 -0
  24. xrefkit/mcp/ownership.py +246 -0
  25. xrefkit/mcp/repository.py +217 -0
  26. xrefkit/mcp/schemas.py +349 -0
  27. xrefkit/mcp/server.py +773 -0
  28. xrefkit/mcp/startup_contract_pack.py +154 -0
  29. xrefkit/mcp_tools.py +47 -0
  30. xrefkit/models/__init__.py +41 -0
  31. xrefkit/models/common.py +185 -0
  32. xrefkit/models/effective_bundle.py +131 -0
  33. xrefkit/models/local_manifest.py +217 -0
  34. xrefkit/models/package_manifest.py +126 -0
  35. xrefkit/models/run_log.py +276 -0
  36. xrefkit/models/server_config.py +160 -0
  37. xrefkit/models/skill_definition.py +131 -0
  38. xrefkit/operations_cli.py +670 -0
  39. xrefkit/ownership.py +276 -0
  40. xrefkit/packmeta.py +289 -0
  41. xrefkit/registry.py +334 -0
  42. xrefkit/resolver.py +252 -0
  43. xrefkit/resource_provider.py +187 -0
  44. xrefkit/resources/base/contracts.json +178 -0
  45. xrefkit/resources/base/current.json +6 -0
  46. xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
  47. xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
  48. xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
  49. xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
  50. xrefkit/resources/base/model_body.md +22 -0
  51. xrefkit/runlog.py +45 -0
  52. xrefkit/skillmeta.py +1034 -0
  53. xrefkit/skillrun.py +2381 -0
  54. xrefkit/structure_catalog.py +199 -0
  55. xrefkit/tools/__init__.py +119 -0
  56. xrefkit/tools/__main__.py +4 -0
  57. xrefkit/v2_cli.py +130 -0
  58. xrefkit/workspace.py +117 -0
  59. xrefkit/xref.py +1048 -0
  60. xrefkit-0.3.0.dist-info/METADATA +203 -0
  61. xrefkit-0.3.0.dist-info/RECORD +65 -0
  62. xrefkit-0.3.0.dist-info/WHEEL +5 -0
  63. xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
  64. xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. xrefkit-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,26 @@
1
+ """Read-only catalog projection for XRefKit repositories."""
2
+
3
+ from .catalog import XRefCatalog
4
+ from .client_cache import DocumentCacheProtocolError, XidDocumentCache
5
+ from .context_registry import PromptContextAssembler, SessionXidContextRegistry
6
+
7
+ __version__ = "0.1.5"
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ from .server import main as server_main
12
+
13
+ args = list(argv or [])
14
+ if args and args[0] == "serve":
15
+ args = args[1:]
16
+ return server_main(args)
17
+
18
+ __all__ = [
19
+ "DocumentCacheProtocolError",
20
+ "PromptContextAssembler",
21
+ "SessionXidContextRegistry",
22
+ "XRefCatalog",
23
+ "XidDocumentCache",
24
+ "main",
25
+ "__version__",
26
+ ]
xrefkit/mcp/audit.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import uuid
7
+ import weakref
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ if os.name == "nt":
15
+ import msvcrt
16
+ else:
17
+ import fcntl
18
+
19
+
20
+ AUDIT_SCHEMA = "xrefkit.mcp_audit/v1"
21
+
22
+
23
+ def _write_all(fd: int, data: bytes, *, write=None) -> None:
24
+ writer = write or os.write
25
+ offset = 0
26
+ while offset < len(data):
27
+ written = writer(fd, data[offset:])
28
+ if written <= 0:
29
+ raise OSError("audit log write did not make progress")
30
+ offset += written
31
+
32
+
33
+ @contextmanager
34
+ def _process_lock(path: Path):
35
+ lock_path = path.with_suffix(path.suffix + ".lock")
36
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
37
+ locked = False
38
+ try:
39
+ if os.name == "nt":
40
+ if os.fstat(fd).st_size == 0:
41
+ _write_all(fd, b"\0")
42
+ os.lseek(fd, 0, os.SEEK_SET)
43
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
44
+ else:
45
+ fcntl.flock(fd, fcntl.LOCK_EX)
46
+ locked = True
47
+ yield
48
+ finally:
49
+ if locked:
50
+ if os.name == "nt":
51
+ os.lseek(fd, 0, os.SEEK_SET)
52
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
53
+ else:
54
+ fcntl.flock(fd, fcntl.LOCK_UN)
55
+ os.close(fd)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SessionRunBinding:
60
+ run_id: str
61
+ mcp_session_id: str
62
+ repository_fingerprint: str
63
+ skill_id: str
64
+
65
+ def to_dict(self) -> dict[str, str]:
66
+ return {
67
+ "run_id": self.run_id,
68
+ "mcp_session_id": self.mcp_session_id,
69
+ "repository_fingerprint": self.repository_fingerprint,
70
+ "skill_id": self.skill_id,
71
+ }
72
+
73
+
74
+ class SessionRunRegistry:
75
+ def __init__(self) -> None:
76
+ self._bindings: "weakref.WeakKeyDictionary[Any, SessionRunBinding]" = weakref.WeakKeyDictionary()
77
+ self._lock = threading.RLock()
78
+
79
+ def bind(
80
+ self,
81
+ session: Any,
82
+ *,
83
+ run_id: str,
84
+ repository_fingerprint: str,
85
+ skill_id: str,
86
+ ) -> SessionRunBinding:
87
+ if session is None:
88
+ raise ValueError("MCP session is required for Skill Run binding")
89
+ try:
90
+ normalized_run_id = str(uuid.UUID(str(run_id)))
91
+ except ValueError as exc:
92
+ raise ValueError(f"run_id must be a UUID: {run_id}") from exc
93
+ normalized_skill_id = str(skill_id).strip()
94
+ normalized_fingerprint = str(repository_fingerprint).strip()
95
+ if not normalized_skill_id:
96
+ raise ValueError("skill_id is required")
97
+ if not normalized_fingerprint:
98
+ raise ValueError("repository_fingerprint is required")
99
+ with self._lock:
100
+ current = self._bindings.get(session)
101
+ if current is not None:
102
+ requested = (normalized_run_id, normalized_fingerprint, normalized_skill_id)
103
+ existing = (current.run_id, current.repository_fingerprint, current.skill_id)
104
+ if requested != existing:
105
+ raise ValueError(
106
+ "MCP session is already bound to a different Skill Run; end the current run before binding another"
107
+ )
108
+ return current
109
+ mcp_session_id = current.mcp_session_id if current else str(uuid.uuid4())
110
+ binding = SessionRunBinding(
111
+ run_id=normalized_run_id,
112
+ mcp_session_id=mcp_session_id,
113
+ repository_fingerprint=normalized_fingerprint,
114
+ skill_id=normalized_skill_id,
115
+ )
116
+ self._bindings[session] = binding
117
+ return binding
118
+
119
+ def end(self, session: Any, *, run_id: str) -> SessionRunBinding:
120
+ if session is None:
121
+ raise ValueError("MCP session is required for Skill Run end")
122
+ try:
123
+ normalized_run_id = str(uuid.UUID(str(run_id)))
124
+ except ValueError as exc:
125
+ raise ValueError(f"run_id must be a UUID: {run_id}") from exc
126
+ with self._lock:
127
+ current = self._bindings.get(session)
128
+ if current is None:
129
+ raise ValueError("MCP session has no active Skill Run binding")
130
+ if current.run_id != normalized_run_id:
131
+ raise ValueError("run_id does not match the active MCP Skill Run binding")
132
+ del self._bindings[session]
133
+ return current
134
+
135
+ def current(self, session: Any) -> SessionRunBinding | None:
136
+ if session is None:
137
+ return None
138
+ with self._lock:
139
+ return self._bindings.get(session)
140
+
141
+
142
+ class McpAuditLog:
143
+ def __init__(self, path: Path) -> None:
144
+ self.path = path.resolve()
145
+ self._lock = threading.Lock()
146
+
147
+ def append(self, event_type: str, *, binding: SessionRunBinding, **fields: object) -> dict[str, object]:
148
+ payload: dict[str, object] = {
149
+ **fields,
150
+ "schema": AUDIT_SCHEMA,
151
+ "timestamp": datetime.now(timezone.utc).isoformat(),
152
+ "event_type": str(event_type),
153
+ **binding.to_dict(),
154
+ }
155
+ line = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
156
+ self.path.parent.mkdir(parents=True, exist_ok=True)
157
+ flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
158
+ if hasattr(os, "O_BINARY"):
159
+ flags |= os.O_BINARY
160
+ with self._lock:
161
+ with _process_lock(self.path):
162
+ fd = os.open(self.path, flags, 0o600)
163
+ try:
164
+ _write_all(fd, line)
165
+ os.fsync(fd)
166
+ finally:
167
+ os.close(fd)
168
+ return payload
@@ -0,0 +1,337 @@
1
+ """Stdlib-only bootstrap client for XRefKit MCP artifact distribution.
2
+
3
+ This script is distributed by the XRefKit MCP server itself at
4
+ ``GET <base-url>/dist/bootstrap.py``. It has no dependency on pip, PyPI, or
5
+ the ``mcp`` package: everything it needs is in the Python standard library,
6
+ so it works on networks where only the XRefKit MCP endpoint is reachable.
7
+
8
+ It downloads distributable artifacts (the xrefkit Skill-execution runtime and the
9
+ client-side tools) over plain HTTP GET from the server's ``/dist`` routes,
10
+ verifies their sha256 against the ``/dist/index.json`` manifest, and either
11
+ materializes the files into the target repository (default) or installs the
12
+ packages with ``pip --no-index``.
13
+
14
+ Package bytes never pass through an MCP tool result, so they never enter the
15
+ model context of an AI client driving this script.
16
+
17
+ Usage:
18
+
19
+ python bootstrap.py --base-url https://host:8443 [--target .]
20
+ [--mode materialize|pip] [--ca-file corp-ca.pem]
21
+ [--startup-context startup.json]
22
+
23
+ ``--startup-context`` additionally performs a minimal MCP handshake
24
+ (JSON-RPC over streamable HTTP, stdlib only) and saves the
25
+ ``get_startup_context`` result, honoring the "startup context first"
26
+ ordering before artifacts are used.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import io
33
+ import json
34
+ import ssl
35
+ import subprocess
36
+ import sys
37
+ import urllib.request
38
+ import zipfile
39
+ from pathlib import Path
40
+
41
+
42
+ DIST_STATE_RELATIVE_PATH = ".xrefkit/dist-state.json"
43
+ MATERIALIZED_PACKAGE_KINDS = {"pip_package"}
44
+ # Package-metadata files at the archive root that describe the package
45
+ # itself and must not be materialized into the target repository root.
46
+ PACKAGE_METADATA_NAMES = {"pyproject.toml", "README.md"}
47
+
48
+
49
+ class BootstrapError(RuntimeError):
50
+ pass
51
+
52
+
53
+ def _ssl_context(ca_file: str | None) -> ssl.SSLContext:
54
+ if ca_file:
55
+ return ssl.create_default_context(cafile=ca_file)
56
+ return ssl.create_default_context()
57
+
58
+
59
+ def http_get(url: str, ca_file: str | None = None, headers: dict[str, str] | None = None) -> bytes:
60
+ request = urllib.request.Request(url, headers=headers or {})
61
+ with urllib.request.urlopen(request, context=_ssl_context(ca_file)) as response:
62
+ return response.read()
63
+
64
+
65
+ def http_post_json(
66
+ url: str,
67
+ payload: dict,
68
+ ca_file: str | None = None,
69
+ headers: dict[str, str] | None = None,
70
+ ) -> tuple[bytes, dict[str, str]]:
71
+ request_headers = {
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json, text/event-stream",
74
+ }
75
+ request_headers.update(headers or {})
76
+ request = urllib.request.Request(
77
+ url,
78
+ data=json.dumps(payload).encode("utf-8"),
79
+ headers=request_headers,
80
+ method="POST",
81
+ )
82
+ with urllib.request.urlopen(request, context=_ssl_context(ca_file)) as response:
83
+ return response.read(), {key.lower(): value for key, value in response.headers.items()}
84
+
85
+
86
+ def parse_jsonrpc_response(raw: bytes) -> dict | None:
87
+ """Parse a streamable-HTTP MCP response body (SSE or plain JSON)."""
88
+ text = raw.decode("utf-8", errors="replace").strip()
89
+ if not text:
90
+ return None
91
+ if not text.startswith("data:") and "\ndata:" not in text and "\rdata:" not in text:
92
+ return json.loads(text)
93
+ result: dict | None = None
94
+ for line in text.splitlines():
95
+ if not line.startswith("data:"):
96
+ continue
97
+ candidate = line[len("data:"):].strip()
98
+ if not candidate:
99
+ continue
100
+ message = json.loads(candidate)
101
+ if isinstance(message, dict) and ("result" in message or "error" in message):
102
+ result = message
103
+ return result
104
+
105
+
106
+ def sha256_hex(content: bytes) -> str:
107
+ return hashlib.sha256(content).hexdigest()
108
+
109
+
110
+ def verify_sha256(content: bytes, expected: str, name: str) -> None:
111
+ actual = sha256_hex(content)
112
+ if actual != expected:
113
+ raise BootstrapError(
114
+ f"sha256 mismatch for {name}: expected {expected}, got {actual}. "
115
+ "Do not use this artifact; the download is corrupt or the server "
116
+ "content changed between the manifest read and the download."
117
+ )
118
+
119
+
120
+ def fetch_index(base_url: str, ca_file: str | None) -> dict:
121
+ raw = http_get(f"{base_url.rstrip('/')}/dist/index.json", ca_file)
122
+ index = json.loads(raw.decode("utf-8"))
123
+ if not isinstance(index, dict) or "artifacts" not in index:
124
+ raise BootstrapError("unexpected /dist/index.json shape: missing 'artifacts'")
125
+ return index
126
+
127
+
128
+ def _stripped_member_path(name: str) -> Path | None:
129
+ """Strip the top-level package directory from an archive member name.
130
+
131
+ Returns None for members that must not be materialized (package
132
+ metadata, unsafe paths, directories).
133
+ """
134
+ normalized = name.replace("\\", "/")
135
+ if normalized.endswith("/"):
136
+ return None
137
+ parts = [part for part in normalized.split("/") if part]
138
+ if len(parts) < 2:
139
+ return None
140
+ stripped = parts[1:]
141
+ if any(part in ("..", "") or ":" in part for part in stripped):
142
+ return None
143
+ if len(stripped) == 1 and stripped[0] in PACKAGE_METADATA_NAMES:
144
+ return None
145
+ return Path(*stripped)
146
+
147
+
148
+ def materialize_package_zip(content: bytes, target_root: Path) -> list[str]:
149
+ """Extract a package zip into the target repo, stripping the package root.
150
+
151
+ The archives are laid out as ``<package_root>/xrefkit/...`` or
152
+ ``<package_root>/tools/...``; the returned relative paths are what was
153
+ written under ``target_root``.
154
+ """
155
+ written: list[str] = []
156
+ resolved_root = target_root.resolve()
157
+ with zipfile.ZipFile(io.BytesIO(content)) as archive:
158
+ for member in archive.infolist():
159
+ relative = _stripped_member_path(member.filename)
160
+ if relative is None:
161
+ continue
162
+ destination = (resolved_root / relative).resolve()
163
+ if resolved_root not in destination.parents and destination != resolved_root:
164
+ raise BootstrapError(f"unsafe archive member path: {member.filename}")
165
+ destination.parent.mkdir(parents=True, exist_ok=True)
166
+ destination.write_bytes(archive.read(member))
167
+ written.append(relative.as_posix())
168
+ return written
169
+
170
+
171
+ def pip_install(paths: list[Path]) -> None:
172
+ if not paths:
173
+ return
174
+ command = [
175
+ sys.executable,
176
+ "-m",
177
+ "pip",
178
+ "install",
179
+ "--no-index",
180
+ "--no-build-isolation",
181
+ *[str(path) for path in paths],
182
+ ]
183
+ print(f"+ {' '.join(command)}")
184
+ completed = subprocess.run(command, check=False)
185
+ if completed.returncode != 0:
186
+ raise BootstrapError(f"pip install failed with exit code {completed.returncode}")
187
+
188
+
189
+ def save_dist_state(target_root: Path, entries: list[dict]) -> Path:
190
+ state_path = target_root / DIST_STATE_RELATIVE_PATH
191
+ state_path.parent.mkdir(parents=True, exist_ok=True)
192
+ state_path.write_text(
193
+ json.dumps({"artifacts": entries}, indent=2, sort_keys=True) + "\n",
194
+ encoding="utf-8",
195
+ )
196
+ return state_path
197
+
198
+
199
+ def fetch_startup_context(base_url: str, mcp_path: str, ca_file: str | None) -> dict:
200
+ """Minimal MCP streamable-HTTP handshake using only the stdlib."""
201
+ endpoint = f"{base_url.rstrip('/')}{mcp_path}"
202
+ raw, headers = http_post_json(
203
+ endpoint,
204
+ {
205
+ "jsonrpc": "2.0",
206
+ "id": 1,
207
+ "method": "initialize",
208
+ "params": {
209
+ "protocolVersion": "2025-03-26",
210
+ "capabilities": {},
211
+ "clientInfo": {"name": "xrefkit-bootstrap", "version": "1"},
212
+ },
213
+ },
214
+ ca_file,
215
+ )
216
+ initialize = parse_jsonrpc_response(raw)
217
+ if not initialize or "result" not in initialize:
218
+ raise BootstrapError(f"MCP initialize failed: {initialize!r}")
219
+ session_id = headers.get("mcp-session-id")
220
+ session_headers = {"mcp-session-id": session_id} if session_id else {}
221
+
222
+ http_post_json(
223
+ endpoint,
224
+ {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
225
+ ca_file,
226
+ session_headers,
227
+ )
228
+ raw, _headers = http_post_json(
229
+ endpoint,
230
+ {
231
+ "jsonrpc": "2.0",
232
+ "id": 2,
233
+ "method": "tools/call",
234
+ "params": {"name": "get_startup_context", "arguments": {}},
235
+ },
236
+ ca_file,
237
+ session_headers,
238
+ )
239
+ response = parse_jsonrpc_response(raw)
240
+ if not response or "result" not in response:
241
+ raise BootstrapError(f"get_startup_context failed: {response!r}")
242
+ return response["result"]
243
+
244
+
245
+ def run(argv: list[str] | None = None) -> int:
246
+ parser = argparse.ArgumentParser(prog="xrefkit-bootstrap", description=__doc__)
247
+ parser.add_argument("--base-url", required=True, help="Server base URL, e.g. https://host:8443")
248
+ parser.add_argument("--target", default=".", help="Client-side target repository root")
249
+ parser.add_argument(
250
+ "--mode",
251
+ choices=["materialize", "pip"],
252
+ default="materialize",
253
+ help="materialize: write xrefkit/ and tools/ files into the target repo; "
254
+ "pip: download packages and pip install them offline",
255
+ )
256
+ parser.add_argument("--ca-file", help="PEM CA bundle for a private/corporate CA")
257
+ parser.add_argument(
258
+ "--mcp-path",
259
+ default="/mcp",
260
+ help="Streamable HTTP MCP path on the same server",
261
+ )
262
+ parser.add_argument(
263
+ "--startup-context",
264
+ help="Also call get_startup_context over MCP JSON-RPC and save the result to this file",
265
+ )
266
+ args = parser.parse_args(argv)
267
+
268
+ base_url = args.base_url.rstrip("/")
269
+ target_root = Path(args.target)
270
+ target_root.mkdir(parents=True, exist_ok=True)
271
+
272
+ if args.startup_context:
273
+ startup = fetch_startup_context(base_url, args.mcp_path, args.ca_file)
274
+ Path(args.startup_context).write_text(
275
+ json.dumps(startup, ensure_ascii=False, indent=2) + "\n",
276
+ encoding="utf-8",
277
+ )
278
+ print(f"saved startup context to {args.startup_context}")
279
+
280
+ index = fetch_index(base_url, args.ca_file)
281
+ state_entries: list[dict] = []
282
+ downloads: list[tuple[dict, bytes]] = []
283
+ for artifact in index["artifacts"]:
284
+ if artifact["kind"] not in MATERIALIZED_PACKAGE_KINDS | {"wheel"}:
285
+ continue
286
+ content = http_get(artifact["url"], args.ca_file)
287
+ verify_sha256(content, artifact["sha256"], artifact["filename"])
288
+ downloads.append((artifact, content))
289
+ print(f"downloaded {artifact['filename']} ({len(content)} bytes, sha256 ok)")
290
+
291
+ if args.mode == "materialize":
292
+ for artifact, content in downloads:
293
+ if artifact["kind"] != "pip_package":
294
+ print(f"skipping {artifact['filename']}: wheels are pip-mode only")
295
+ continue
296
+ written = materialize_package_zip(content, target_root)
297
+ print(f"materialized {len(written)} files from {artifact['filename']}")
298
+ state_entries.append(
299
+ {
300
+ "filename": artifact["filename"],
301
+ "package_id": artifact.get("package_id"),
302
+ "version": artifact.get("version"),
303
+ "sha256": artifact["sha256"],
304
+ "mode": "materialize",
305
+ "files": written,
306
+ }
307
+ )
308
+ else:
309
+ download_dir = target_root / ".xrefkit" / "dist"
310
+ download_dir.mkdir(parents=True, exist_ok=True)
311
+ saved_paths: list[Path] = []
312
+ for artifact, content in downloads:
313
+ path = download_dir / artifact["filename"]
314
+ path.write_bytes(content)
315
+ saved_paths.append(path)
316
+ state_entries.append(
317
+ {
318
+ "filename": artifact["filename"],
319
+ "package_id": artifact.get("package_id"),
320
+ "version": artifact.get("version"),
321
+ "sha256": artifact["sha256"],
322
+ "mode": "pip",
323
+ }
324
+ )
325
+ pip_install(saved_paths)
326
+
327
+ state_path = save_dist_state(target_root, state_entries)
328
+ print(f"recorded dist state at {state_path}")
329
+ return 0
330
+
331
+
332
+ if __name__ == "__main__":
333
+ try:
334
+ raise SystemExit(run())
335
+ except BootstrapError as error:
336
+ print(f"bootstrap failed: {error}", file=sys.stderr)
337
+ raise SystemExit(1)