dcc-mcp-cli 0.20.35__py3-none-win_amd64.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.
@@ -0,0 +1,32 @@
1
+ """PyPI wrapper that installs and runs the ``dcc-mcp-cli`` binary.
2
+
3
+ ``pip install dcc-mcp-cli`` (or ``uvx dcc-mcp-cli``) puts a ``dcc-mcp-cli``
4
+ command on ``PATH``. The wheel carries the compressed release archive of the
5
+ Rust binary for the installing platform and unpacks it into the environment's
6
+ scripts directory on first use, so the executing process is the real binary.
7
+
8
+ Most users never import this package. Programmatic callers can use
9
+ :func:`binary_path` to spawn the binary without searching ``PATH``::
10
+
11
+ import subprocess
12
+ from dcc_mcp_cli import binary_path
13
+
14
+ subprocess.run([str(binary_path()), "--version"])
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ._bootstrap import PACKAGE_MANAGER_MARKER
20
+ from ._bootstrap import PayloadError
21
+ from ._bootstrap import binary_path
22
+ from ._bootstrap import main
23
+
24
+ __all__ = [
25
+ "PACKAGE_MANAGER_MARKER",
26
+ "PayloadError",
27
+ "__version__",
28
+ "binary_path",
29
+ "main",
30
+ ]
31
+
32
+ __version__ = "0.20.35" # x-release-please-version
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m dcc_mcp_cli`` to run the bundled CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._bootstrap import main
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,461 @@
1
+ r"""Unpack and execute the ``dcc-mcp-cli`` binary shipped inside this wheel.
2
+
3
+ The wheel carries the same compressed release archive the GitHub Release
4
+ publishes (``dcc-mcp-cli-<version>-<platform>.zip``) rather than the raw
5
+ executable: PyPI meters a project's *total* storage (10 GB by default), and the
6
+ deflated archive costs roughly a third of the raw binary per release.
7
+
8
+ The archive is unpacked lazily, on the first invocation, into the environment's
9
+ scripts directory (``<venv>/bin`` or ``<venv>\\Scripts``) and then executed. Two
10
+ properties fall out of that choice:
11
+
12
+ * The running process *is* the real binary, so ``std::env::current_exe()``
13
+ resolves inside the scripts directory. ``dcc-mcp-cli components ensure
14
+ dcc-cua`` installs its companion executable as a sibling of
15
+ ``current_exe``, so a single directory keeps the component contract intact.
16
+ * The unpacked binary is package-manager owned. A marker file next to it
17
+ (``dcc-mcp-cli.package-manager.json``) tells the CLI that self-update is
18
+ disabled and that upgrades belong to the package manager.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from pathlib import Path
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import sysconfig
32
+ import tempfile
33
+ import zipfile
34
+
35
+ __all__ = [
36
+ "PACKAGE_MANAGER_MARKER",
37
+ "binary_path",
38
+ "main",
39
+ "payload_dir",
40
+ "resolve_binary",
41
+ ]
42
+
43
+ #: Marker written next to the unpacked binary. ``crates/dcc-mcp-cli`` reads the
44
+ #: same file name to decide whether a package manager owns the installation.
45
+ PACKAGE_MANAGER_MARKER = "dcc-mcp-cli.package-manager.json"
46
+
47
+ #: Name of the manifest the release script stages next to the archive.
48
+ PAYLOAD_METADATA = "payload.json"
49
+
50
+ #: Package manager recorded in the marker. Read by the CLI's update command.
51
+ PACKAGE_MANAGER_NAME = "pypi"
52
+
53
+ _PAYLOAD_SCHEMA_VERSION = 1
54
+ _COPY_CHUNK_BYTES = 1024 * 1024
55
+ _EXECUTABLE_MODE = 0o755
56
+ #: Python console-script launchers start with a shebang. A native binary
57
+ #: never does, which is how a reinstalled launcher is told apart from the
58
+ #: binary it replaced.
59
+ _LAUNCHER_MAGIC = b"#!"
60
+
61
+
62
+ class PayloadError(RuntimeError):
63
+ """Raised when the wheel does not carry a usable CLI payload."""
64
+
65
+
66
+ def payload_dir() -> Path:
67
+ """Return the directory holding the bundled CLI archive.
68
+
69
+ Returns:
70
+ Absolute path of ``dcc_mcp_cli/_payload`` inside the installation.
71
+
72
+ Raises:
73
+ PayloadError: if the payload directory is missing.
74
+
75
+ """
76
+ directory = Path(__file__).resolve().parent / "_payload"
77
+ if not directory.is_dir():
78
+ raise PayloadError(
79
+ f"{directory} is missing; this wheel was built without a dcc-mcp-cli payload. "
80
+ "Build it with scripts/release/build_cli_wrapper_wheel.py."
81
+ )
82
+ return directory
83
+
84
+
85
+ def load_payload() -> dict:
86
+ """Read and validate the bundled payload manifest.
87
+
88
+ Returns:
89
+ The parsed ``payload.json`` mapping.
90
+
91
+ Raises:
92
+ PayloadError: if the manifest or its archive is missing or malformed.
93
+
94
+ """
95
+ directory = payload_dir()
96
+ metadata_path = directory / PAYLOAD_METADATA
97
+ try:
98
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
99
+ except OSError as exc:
100
+ raise PayloadError(f"cannot read {metadata_path}: {exc}") from exc
101
+ except ValueError as exc:
102
+ raise PayloadError(f"{metadata_path} is not valid JSON: {exc}") from exc
103
+
104
+ if not isinstance(metadata, dict):
105
+ raise PayloadError(f"{metadata_path} must contain a JSON object")
106
+ if int(metadata.get("schema_version", 0)) != _PAYLOAD_SCHEMA_VERSION:
107
+ raise PayloadError(
108
+ f"{metadata_path} declares schema_version {metadata.get('schema_version')!r}, "
109
+ f"expected {_PAYLOAD_SCHEMA_VERSION}"
110
+ )
111
+ for key in ("version", "platform", "archive", "member"):
112
+ value = metadata.get(key)
113
+ if not isinstance(value, str) or not value:
114
+ raise PayloadError(f"{metadata_path} is missing a non-empty {key!r}")
115
+ if not (directory / metadata["archive"]).is_file():
116
+ raise PayloadError(f"payload archive {metadata['archive']!r} is missing from {directory}")
117
+ return metadata
118
+
119
+
120
+ def _binary_names() -> tuple:
121
+ """Return candidate file names for the unpacked binary, best first.
122
+
123
+ POSIX may reuse ``dcc-mcp-cli`` itself: replacing the console script with
124
+ the real binary puts the native executable directly on ``PATH``. Windows
125
+ cannot — the pip-generated ``dcc-mcp-cli.exe`` launcher is the running
126
+ process image and cannot be replaced while it is mapped — so the unpacked
127
+ binary always takes a distinct name there.
128
+ """
129
+ suffix = ".exe" if os.name == "nt" else ""
130
+ if os.name == "nt":
131
+ return (f"dcc-mcp-cli-bin{suffix}",)
132
+ return (f"dcc-mcp-cli{suffix}", f"dcc-mcp-cli-bin{suffix}")
133
+
134
+
135
+ def _user_fallback_dir() -> Path:
136
+ """Return a per-user directory used when the scripts dir is not writable."""
137
+ if os.name == "nt":
138
+ appdata = os.environ.get("LOCALAPPDATA")
139
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Local"
140
+ return base / "dcc-mcp-cli" / "bin"
141
+ return Path.home() / ".local" / "share" / "dcc-mcp-cli" / "bin"
142
+
143
+
144
+ def candidate_dirs() -> list:
145
+ """Return directories the binary may live in, most preferred first.
146
+
147
+ ``DCC_MCP_CLI_BIN_DIR`` wins so operators can pin the location (read-only
148
+ environments, container images). Otherwise the interpreter's scripts
149
+ directory comes first, then the user-local fallback for prefixes the
150
+ current user cannot write to.
151
+ """
152
+ candidates = []
153
+ override = os.environ.get("DCC_MCP_CLI_BIN_DIR")
154
+ if override:
155
+ candidates.append(Path(override).expanduser())
156
+ scripts = sysconfig.get_path("scripts")
157
+ if scripts:
158
+ candidates.append(Path(scripts))
159
+ candidates.append(Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin"))
160
+ candidates.append(_user_fallback_dir())
161
+
162
+ ordered = []
163
+ for candidate in candidates:
164
+ resolved = Path(os.path.normpath(str(candidate)))
165
+ if resolved not in ordered:
166
+ ordered.append(resolved)
167
+ return ordered
168
+
169
+
170
+ def marker_path(directory: Path) -> Path:
171
+ """Return the package-manager marker path for one directory.
172
+
173
+ Args:
174
+ directory: Directory holding (or about to hold) the CLI binary.
175
+
176
+ Returns:
177
+ Path of the marker file inside ``directory``.
178
+
179
+ """
180
+ return directory / PACKAGE_MANAGER_MARKER
181
+
182
+
183
+ def _read_marker(directory: Path) -> dict | None:
184
+ """Return the package-manager marker in ``directory``, if it parses."""
185
+ path = marker_path(directory)
186
+ try:
187
+ marker = json.loads(path.read_text(encoding="utf-8"))
188
+ except (OSError, ValueError):
189
+ return None
190
+ return marker if isinstance(marker, dict) else None
191
+
192
+
193
+ def _marker_matches(marker: dict, payload: dict) -> bool:
194
+ """Report whether ``marker`` was written for this exact payload version."""
195
+ return marker.get("version") == payload.get("version") and marker.get("platform") == payload.get("platform")
196
+
197
+
198
+ def _is_launcher(path: Path) -> bool:
199
+ """Report whether ``path`` looks like a Python console-script launcher.
200
+
201
+ Reinstalling the *same* wheel version rewrites the pip-generated console
202
+ script over the unpacked binary. The marker is not part of the
203
+ distribution RECORD, so it survives that reinstall and would otherwise
204
+ vouch for a file that is a launcher again: :func:`_execute` would then
205
+ ``execv`` it, re-enter ``main()`` and loop forever without output.
206
+ """
207
+ try:
208
+ with path.open("rb") as stream:
209
+ return stream.read(len(_LAUNCHER_MAGIC)) == _LAUNCHER_MAGIC
210
+ except OSError:
211
+ return True
212
+
213
+
214
+ def _binary_is_intact(path: Path, marker: dict) -> bool:
215
+ """Report whether ``path`` still holds the binary ``marker`` describes.
216
+
217
+ Args:
218
+ path: Candidate binary sitting next to the marker.
219
+ marker: Parsed package-manager marker.
220
+
221
+ Returns:
222
+ ``True`` when the file is neither a console-script launcher nor a
223
+ differently sized file than the one that was unpacked.
224
+
225
+ """
226
+ if _is_launcher(path):
227
+ return False
228
+ recorded = marker.get("binary_size")
229
+ if not isinstance(recorded, int) or isinstance(recorded, bool):
230
+ # Marker written before the fingerprint existed: fall back to the
231
+ # launcher check alone so an older installation is not orphaned.
232
+ return True
233
+ try:
234
+ return path.stat().st_size == recorded
235
+ except OSError:
236
+ return False
237
+
238
+
239
+ def _find_existing(payload: dict) -> Path | None:
240
+ """Return an already unpacked binary matching ``payload``, if any."""
241
+ for directory in candidate_dirs():
242
+ marker = _read_marker(directory)
243
+ if marker is None or not _marker_matches(marker, payload):
244
+ continue
245
+ for name in _binary_names():
246
+ candidate = directory / name
247
+ if candidate.is_file() and _binary_is_intact(candidate, marker):
248
+ return candidate
249
+ return None
250
+
251
+
252
+ def _extract(archive: Path, member: str, destination: Path) -> None:
253
+ """Unpack one archive member to ``destination`` atomically.
254
+
255
+ Args:
256
+ archive: Release zip holding the CLI binary.
257
+ member: Archive member to unpack.
258
+ destination: Final path of the executable.
259
+
260
+ Raises:
261
+ PayloadError: if the archive does not contain ``member``.
262
+
263
+ """
264
+ destination.parent.mkdir(parents=True, exist_ok=True)
265
+ handle, temporary_name = tempfile.mkstemp(
266
+ prefix=destination.name + ".",
267
+ suffix=".tmp",
268
+ dir=str(destination.parent),
269
+ )
270
+ os.close(handle)
271
+ temporary = Path(temporary_name)
272
+ try:
273
+ with zipfile.ZipFile(archive) as archive_file:
274
+ try:
275
+ source = archive_file.open(member)
276
+ except KeyError as exc:
277
+ raise PayloadError(f"{archive.name} does not contain {member!r}") from exc
278
+ with source, temporary.open("wb") as target:
279
+ shutil.copyfileobj(source, target, length=_COPY_CHUNK_BYTES)
280
+ temporary.chmod(_EXECUTABLE_MODE)
281
+ temporary.replace(destination)
282
+ except BaseException:
283
+ if temporary.exists():
284
+ with contextlib.suppress(OSError): # best effort cleanup
285
+ temporary.unlink()
286
+ raise
287
+
288
+
289
+ def _write_marker(directory: Path, payload: dict, binary_name: str) -> None:
290
+ """Record that a package manager owns the binary in ``directory``.
291
+
292
+ Args:
293
+ directory: Directory holding the unpacked binary.
294
+ payload: Payload manifest the binary was unpacked from.
295
+ binary_name: File name of the unpacked binary.
296
+
297
+ """
298
+ marker = {
299
+ "schema_version": _PAYLOAD_SCHEMA_VERSION,
300
+ "distribution": payload.get("distribution", "dcc-mcp-cli"),
301
+ "manager": PACKAGE_MANAGER_NAME,
302
+ "version": payload["version"],
303
+ "platform": payload["platform"],
304
+ "binary": binary_name,
305
+ }
306
+ # Fingerprint the unpacked binary so a reinstall that restores the pip
307
+ # console script cannot be mistaken for a healthy installation.
308
+ with contextlib.suppress(OSError):
309
+ marker["binary_size"] = (directory / binary_name).stat().st_size
310
+ directory.mkdir(parents=True, exist_ok=True)
311
+ temporary = directory / (PACKAGE_MANAGER_MARKER + ".tmp")
312
+ temporary.write_text(json.dumps(marker, indent=2, sort_keys=True) + "\n", encoding="utf-8")
313
+ temporary.replace(marker_path(directory))
314
+
315
+
316
+ def _archive_digest(archive: Path, expected: str | None) -> None:
317
+ """Verify the staged archive against the digest recorded at build time.
318
+
319
+ Args:
320
+ archive: Release zip to verify.
321
+ expected: Lowercase hex SHA-256 recorded in ``payload.json``.
322
+
323
+ Raises:
324
+ PayloadError: if the digest is missing or does not match.
325
+
326
+ """
327
+ if not expected:
328
+ raise PayloadError(f"{archive.name} has no archive_sha256 in {PAYLOAD_METADATA}; rebuild the wrapper wheel")
329
+ digest = hashlib.sha256()
330
+ with archive.open("rb") as stream:
331
+ for chunk in iter(lambda: stream.read(_COPY_CHUNK_BYTES), b""):
332
+ digest.update(chunk)
333
+ if digest.hexdigest() != expected:
334
+ raise PayloadError(f"{archive.name} sha256 mismatch: expected {expected}, got {digest.hexdigest()}")
335
+
336
+
337
+ def install_binary(payload: dict) -> Path:
338
+ """Unpack the bundled archive into the first writable candidate directory.
339
+
340
+ Args:
341
+ payload: Parsed payload manifest.
342
+
343
+ Returns:
344
+ Path of the unpacked executable.
345
+
346
+ Raises:
347
+ PayloadError: if no candidate directory accepts the binary.
348
+
349
+ """
350
+ archive = payload_dir() / payload["archive"]
351
+ _archive_digest(archive, payload.get("archive_sha256"))
352
+
353
+ failures = []
354
+ for directory in candidate_dirs():
355
+ for name in _binary_names():
356
+ destination = directory / name
357
+ try:
358
+ _extract(archive, payload["member"], destination)
359
+ except (OSError, PayloadError) as exc:
360
+ failures.append(f"{destination}: {exc}")
361
+ continue
362
+ try:
363
+ _write_marker(directory, payload, name)
364
+ except OSError as exc: # pragma: no cover - rare, directory just worked
365
+ failures.append(f"{marker_path(directory)}: {exc}")
366
+ continue
367
+ print(
368
+ f"info: unpacked dcc-mcp-cli {payload['version']} ({payload['platform']}) to {destination}",
369
+ file=sys.stderr,
370
+ )
371
+ return destination
372
+
373
+ raise PayloadError(
374
+ "could not unpack the dcc-mcp-cli binary into any candidate directory:\n " + "\n ".join(failures)
375
+ )
376
+
377
+
378
+ def resolve_binary() -> Path:
379
+ """Return the path of the CLI binary, unpacking it on first use.
380
+
381
+ Returns:
382
+ Path of the executable to run.
383
+
384
+ Raises:
385
+ PayloadError: if the payload is unusable or cannot be unpacked.
386
+
387
+ """
388
+ payload = load_payload()
389
+ existing = _find_existing(payload)
390
+ return existing if existing is not None else install_binary(payload)
391
+
392
+
393
+ def binary_path() -> Path:
394
+ """Return the filesystem path of the bundled ``dcc-mcp-cli`` binary.
395
+
396
+ Resolution order:
397
+
398
+ 1. ``DCC_MCP_CLI_BIN`` env var — operator override to an existing binary.
399
+ 2. An already unpacked binary whose marker matches this wheel's payload.
400
+ 3. The bundled archive unpacked into the environment's scripts directory.
401
+
402
+ Returns:
403
+ Path of the executable.
404
+
405
+ Raises:
406
+ PayloadError: if no binary can be resolved.
407
+
408
+ """
409
+ override = os.environ.get("DCC_MCP_CLI_BIN")
410
+ if override:
411
+ candidate = Path(override).expanduser()
412
+ if candidate.is_file():
413
+ return candidate
414
+ return resolve_binary()
415
+
416
+
417
+ def _execute(binary: Path, argv: list) -> int:
418
+ """Run the binary and return its exit code.
419
+
420
+ POSIX replaces the current process so signals, exit codes and TTY state
421
+ behave exactly like a native invocation. Windows cannot replace a running
422
+ image, so it runs the binary as a child process instead.
423
+
424
+ Args:
425
+ binary: Executable to run.
426
+ argv: Arguments to pass through.
427
+
428
+ Returns:
429
+ The process exit code (Windows only; POSIX never returns).
430
+
431
+ """
432
+ sys.stdout.flush()
433
+ sys.stderr.flush()
434
+ command = [str(binary)]
435
+ command.extend(argv)
436
+ if os.name == "nt":
437
+ try:
438
+ return int(subprocess.run(command).returncode)
439
+ except KeyboardInterrupt:
440
+ return 130
441
+ os.execv(str(binary), command)
442
+ return 1 # pragma: no cover - os.execv does not return
443
+
444
+
445
+ def main(argv: list | None = None) -> int:
446
+ """Unpack (if needed) and run the bundled ``dcc-mcp-cli`` binary.
447
+
448
+ Args:
449
+ argv: Arguments forwarded to the CLI. Defaults to ``sys.argv[1:]``.
450
+
451
+ Returns:
452
+ The CLI's exit code.
453
+
454
+ """
455
+ arguments = list(sys.argv[1:] if argv is None else argv)
456
+ try:
457
+ binary = binary_path()
458
+ except PayloadError as exc:
459
+ print(f"error: {exc}", file=sys.stderr)
460
+ return 1
461
+ return _execute(binary, arguments)
@@ -0,0 +1,10 @@
1
+ {
2
+ "archive": "dcc-mcp-cli-0.20.35-windows-x86_64.zip",
3
+ "archive_sha256": "65947d1a13f9a46fb810d50583a3834e920b3b11b6976083dec64f9618a25f71",
4
+ "distribution": "dcc-mcp-cli",
5
+ "member": "dcc-mcp-cli.exe",
6
+ "platform": "windows-x86_64",
7
+ "schema_version": 1,
8
+ "version": "0.20.35",
9
+ "wheel_platform_tag": "win_amd64"
10
+ }
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: dcc-mcp-cli
3
+ Version: 0.20.35
4
+ Summary: Standalone dcc-mcp-cli binary — DCC-MCP install, gateway and component control (Rust)
5
+ Project-URL: Homepage, https://github.com/dcc-mcp/dcc-mcp-core
6
+ Project-URL: Repository, https://github.com/dcc-mcp/dcc-mcp-core
7
+ Project-URL: Issues, https://github.com/dcc-mcp/dcc-mcp-core/issues
8
+ Author-email: Hal Long <hal.long@outlook.com>
9
+ License: MIT
10
+ Keywords: blender,cli,dcc,gateway,houdini,installer,maya,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.7
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Programming Language :: Rust
26
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
27
+ Classifier: Topic :: Software Development
28
+ Classifier: Topic :: System :: Systems Administration
29
+ Requires-Python: >=3.7
30
+ Provides-Extra: dev
31
+ Requires-Dist: build>=1.2; extra == 'dev'
32
+ Requires-Dist: hatchling>=1.25; extra == 'dev'
33
+ Requires-Dist: wheel>=0.46; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # dcc-mcp-cli-bin — PyPI wrapper for the `dcc-mcp-cli` binary
37
+
38
+ This directory builds the `dcc-mcp-cli` PyPI project: one platform-specific
39
+ wheel per release that puts the Rust CLI on `PATH`.
40
+
41
+ ```bash
42
+ # Run it without installing anything
43
+ uvx dcc-mcp-cli --version
44
+
45
+ # Or install it into a tool environment / venv
46
+ uv tool install dcc-mcp-cli
47
+ pip install dcc-mcp-cli
48
+ ```
49
+
50
+ ## Why a wrapper instead of a raw binary wheel?
51
+
52
+ `pkg/dcc-mcp-server-bin` ships its raw executable through maturin's
53
+ `bindings = "bin"`. `dcc-mcp-cli` deliberately does not:
54
+
55
+ | | Raw binary wheel | This wrapper |
56
+ |---|---|---|
57
+ | Per-release PyPI storage | ~165 MB (three platforms) | ~66 MB (three platforms) |
58
+ | PyPI 10 GB budget | ~60 releases | ~152 releases |
59
+ | Payload | the executable | the release zip, unpacked on first use |
60
+
61
+ The wheel embeds the same `dcc-mcp-cli-<version>-<platform>.zip` the GitHub
62
+ Release publishes, verifies its SHA-256 against `payload.json`, and unpacks it
63
+ into the environment's scripts directory on the first invocation.
64
+
65
+ ## Layout
66
+
67
+ ```
68
+ pkg/dcc-mcp-cli-bin/
69
+ ├── pyproject.toml # hatchling; console script dcc-mcp-cli
70
+ ├── python/dcc_mcp_cli/
71
+ │ ├── __init__.py # public API: binary_path(), main()
72
+ │ ├── __main__.py # python -m dcc_mcp_cli
73
+ │ ├── _bootstrap.py # unpack + exec
74
+ │ └── _payload/ # staged at build time, git-ignored
75
+ │ ├── payload.json
76
+ │ └── dcc-mcp-cli-<version>-<platform>.zip
77
+ └── tests/test_cli_bootstrap.py
78
+ ```
79
+
80
+ ## Where the binary lands, and why it matters
81
+
82
+ The bootstrap unpacks the executable into the scripts directory of the
83
+ interpreter that installed the wheel — `<venv>/bin` on POSIX,
84
+ `<venv>\Scripts` on Windows — and then executes it. Consequences:
85
+
86
+ - **Component contract.** `current_exe.parent()` resolves to that directory, so
87
+ `dcc-mcp-cli components ensure dcc-cua` installs `dcc-cua` as a sibling and
88
+ detects it on the next run. A package manager must therefore keep the CLI in
89
+ a *single* directory; unpacking into `site-packages` would break it.
90
+ - **POSIX vs Windows naming.** On POSIX the unpacked binary replaces the
91
+ console script, so `dcc-mcp-cli` becomes the native executable directly.
92
+ On Windows the pip-generated `dcc-mcp-cli.exe` launcher is the running
93
+ process image and cannot be replaced while mapped, so the binary is unpacked
94
+ as `dcc-mcp-cli-bin.exe` and the launcher execs it.
95
+ - **Read-only prefixes.** If the scripts directory is not writable, the
96
+ bootstrap falls back to a per-user directory
97
+ (`~/.local/share/dcc-mcp-cli/bin`, or `%LOCALAPPDATA%\dcc-mcp-cli\bin`).
98
+ Set `DCC_MCP_CLI_BIN_DIR` to pin the location explicitly.
99
+
100
+ `dcc-cua` is intentionally **not** distributed here. It is an independently
101
+ released companion executable that `dcc-mcp-cli components ensure` reconciles
102
+ by version next to the CLI; shipping it as a second distribution would create
103
+ two copies and version drift.
104
+
105
+ ## Self-update is disabled
106
+
107
+ The bootstrap writes `dcc-mcp-cli.package-manager.json` next to the unpacked
108
+ binary. `crates/dcc-mcp-cli` reads that marker and refuses `update apply`:
109
+
110
+ ```
111
+ error: this dcc-mcp-cli was installed by a package manager (pypi)
112
+ ```
113
+
114
+ The GitHub Release build keeps self-update; the package-manager build does
115
+ not, because replacing `current_exe` would fight the package manager's version
116
+ authority. Upgrade through the package manager instead:
117
+
118
+ ```bash
119
+ uv tool upgrade dcc-mcp-cli
120
+ pipx upgrade dcc-mcp-cli
121
+ pip install --upgrade dcc-mcp-cli
122
+ ```
123
+
124
+ ## Building locally
125
+
126
+ ```bash
127
+ # 1. Build the release archive (example: current platform)
128
+ cargo build --release -p dcc-mcp-cli
129
+ python scripts/release/build_standalone_bundle.py \
130
+ --version 0.20.34 \
131
+ --platform windows-x86_64 \
132
+ --binary-name dcc-mcp-cli \
133
+ --binary-path target/release/dcc-mcp-cli.exe \
134
+ --out-dir dist
135
+
136
+ # 2. Stage the archive and build the retagged wheel
137
+ python -m pip install "build>=1.2" "hatchling>=1.25" "wheel>=0.46"
138
+ python scripts/release/build_cli_wrapper_wheel.py \
139
+ --version 0.20.34 \
140
+ --platform windows-x86_64 \
141
+ --zip dist/dcc-mcp-cli-0.20.34-windows-x86_64.zip \
142
+ --out-dir wheels
143
+ python scripts/release/cli_wheel_tags.py validate --wheel-dir wheels --version 0.20.34
144
+ ```
145
+
146
+ CI runs the same three steps in the `build-cli-wheels` job of
147
+ `.github/workflows/release.yml`, once per platform, from the archives the
148
+ `build-binaries` job already produced.
149
+
150
+ ## Python compatibility
151
+
152
+ The bootstrap is pure Python with no third-party imports and runs unchanged on
153
+ Python 3.7 (Maya 2022), so the project's LTS floor applies: the wheel declares
154
+ `Requires-Python: >=3.7` and carries the full 3.7–3.14 classifier set. The
155
+ binary it unpacks has no CPython ABI dependency — the interpreter only ever
156
+ starts it as a child process.
@@ -0,0 +1,9 @@
1
+ dcc_mcp_cli/__init__.py,sha256=mFJ7psGJ8zHlPmjqeu5DAbZgqwVqblHKStYuGQ48x98,989
2
+ dcc_mcp_cli/__main__.py,sha256=A9lAA87yd2cwQyN8CIproOD9MO55PdCDBT49VyZh_Ps,209
3
+ dcc_mcp_cli/_bootstrap.py,sha256=g7g4bz0UqwdT8uTVZm-oqV3mWQ76qjE-QUo_ATNEYSo,16092
4
+ dcc_mcp_cli/_payload/dcc-mcp-cli-0.20.35-windows-x86_64.zip,sha256=ZZR9GhP5pG-4ENUFg6ODTpILOxG2l2CD3sZPlhiiX3E,16016560
5
+ dcc_mcp_cli/_payload/payload.json,sha256=GwcaiG8z7ZIScaiIAdD_AikOsuDs1jxRa4Du7Mpwto8,326
6
+ dcc_mcp_cli-0.20.35.dist-info/METADATA,sha256=udBbn1txRZYbE7orUd9j3WmMN-dpWX2flgztr1iIFnM,6398
7
+ dcc_mcp_cli-0.20.35.dist-info/WHEEL,sha256=n-eighM0qgE1od73sDmNJWeHnMjpj01ZqRpq9FHlr0c,94
8
+ dcc_mcp_cli-0.20.35.dist-info/entry_points.txt,sha256=OZDQEDvH1UCwC1ihNlmLAPwogTH1ish-C41eXpDuelM,49
9
+ dcc_mcp_cli-0.20.35.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dcc-mcp-cli = dcc_mcp_cli:main