#!/usr/bin/env python3 """Build the distributable nexuscrm-connect download artifacts. Produces, deterministically (byte-identical for identical source trees): - ``nexuscrm-connect-cli.tgz`` — the toolkit tarball: ``install.sh`` at the archive root plus the full ``nexuscrm-connect/`` package, so "extract, then run ``./install.sh``" works as documented. - ``nexuscrm-connect-plugin.dxt`` — the Claude Code plugin bundle: a zip with ``.claude-plugin/plugin.json`` at the archive root (SKILLS ONLY — it declares no MCP server; ``install.sh`` from the CLI artifact registers the local stdio one). The release deploy runs this script and then publishes both files into the backend object store via `` publish-artifact`` so the platform's app-blind ``GET /downloads/{cli|plugin}/latest`` endpoint can serve them. ``--mcp-url`` carries the deploying environment's own public endpoint so a test/staging download never talks to production. It used to stamp the plugin manifest as well; the plugin declares no server now, so its single remaining consumer is the local-stdio ``mcp/mcp.json`` snippet, whose ``NEXUS_BASE_URL`` becomes the URL minus the ``/mcp`` suffix. The flag keeps its name and shape so deploy callers do not change. When omitted, the source snippet ships as-is (local/dev builds and determinism checks). No npm install happens here: runtime dependencies are installed on the user's machine by ``install.sh``. Only stdlib is used, so the script runs anywhere the deploy runners run. Usage: python3 agent-tools/build_artifacts.py --out-dir [--mcp-url ] Prints a single JSON object on stdout: {"version": ..., "cli": {"path": ..., "sha256": ...}, "plugin": {...}} """ from __future__ import annotations import argparse import gzip import hashlib import io import json import stat import sys import tarfile import zipfile from pathlib import Path CLI_ARTIFACT_FILENAME = "nexuscrm-connect-cli.tgz" PLUGIN_ARTIFACT_FILENAME = "nexuscrm-connect-plugin.dxt" PACKAGE_DIR_NAME = "nexuscrm-connect" # Never ship dev/user residue. Mirrors agent-tools/.gitignore. EXCLUDED_NAMES = {"node_modules", ".DS_Store", "token.json", "mcp.local.json", "dist"} EXCLUDED_SUFFIX_MARKER = ".bak." # Deterministic timestamps: tar allows the epoch; zip's minimum is 1980-01-01. TAR_MTIME = 0 ZIP_DATE_TIME = (1980, 1, 1, 0, 0, 0) class BuildError(RuntimeError): pass def is_excluded(name: str) -> bool: return name in EXCLUDED_NAMES or EXCLUDED_SUFFIX_MARKER in name def package_version(package_dir: Path) -> str: package_json = package_dir / "package.json" try: manifest = json.loads(package_json.read_text()) except FileNotFoundError as exc: raise BuildError(f"missing package manifest: {package_json}") from exc except json.JSONDecodeError as exc: raise BuildError(f"invalid package manifest {package_json}: {exc}") from exc version = str(manifest.get("version", "")).strip() if not version: raise BuildError(f"package manifest has no version: {package_json}") return version def collect_files(root: Path) -> list[Path]: """All shippable files under root, sorted by archive path for determinism.""" if not root.is_dir(): raise BuildError(f"source directory not found: {root}") files: list[Path] = [] for path in sorted(root.rglob("*")): relative_parts = path.relative_to(root).parts if any(is_excluded(part) for part in relative_parts): continue if path.is_symlink(): raise BuildError(f"refusing to package symlink: {path}") if path.is_file(): files.append(path) if not files: raise BuildError(f"no shippable files found under {root}") return files def normalized_mode(path: Path) -> int: return 0o755 if path.stat().st_mode & stat.S_IXUSR else 0o644 def validate_plugin_manifest(package_dir: Path) -> None: """The plugin ships SKILLS ONLY and must declare no MCP server. Layout note, because review flagged this bundle as inert: the skill is a root-level ``SKILL.md`` with no ``skills/`` directory, which is a supported single-skill plugin layout (Claude Code >= v2.1.142 loads it without a ``skills`` manifest field). Its invocation name comes from the SKILL.md frontmatter ``name``. Do not "fix" this by adding a ``skills`` key — for a marketplace source that resolves to the root, declaring subdirectories REPLACES the default scan rather than adding to it. It used to declare the hosted ``/mcp`` endpoint. That endpoint is L0 platform code and serves four schema-agnostic tools with no business semantics, no ``get_semantics`` and no diagnostics, while every business rule lives in the local stdio server that ``install.sh`` registers. Production showed the entry had never once been authorized — zero dynamic client registrations, every refresh token belonging to the stdio path — so all it did was register a second server named ``nexuscrm`` and leave a standing authorization prompt that, if anyone ever accepted it, would handed them a semantics-free agent that looked healthy. Asserted rather than merely deleted: a manifest is exactly the kind of file where a server entry gets pasted back in "to make the plugin work", and that is the failure to catch. """ manifest_path = package_dir / ".claude-plugin" / "plugin.json" try: manifest = json.loads(manifest_path.read_text()) except FileNotFoundError as exc: raise BuildError(f"missing plugin manifest: {manifest_path}") from exc except json.JSONDecodeError as exc: raise BuildError(f"invalid plugin manifest {manifest_path}: {exc}") from exc if manifest.get("mcpServers"): raise BuildError( f"plugin manifest must not declare mcpServers: {manifest_path}. The hosted /mcp " "endpoint carries no business semantics, and a bundled stdio server cannot work from " "this artifact because node_modules is excluded and the plugin format has no install " "step. MCP registration is install.sh's job." ) for key in ("clientId", "client_id", "clientSecret", "client_secret", "oauth"): if key in manifest: raise BuildError(f"plugin manifest must not carry OAuth client settings ({key}): {manifest_path}") def mcp_local_registration_override(package_dir: Path, base_url: str) -> tuple[Path, bytes] | None: """The bytes to ship for the local-stdio ``mcp/mcp.json`` registration snippet with ``NEXUS_BASE_URL`` rewritten to the deploying environment's base URL. ``mcp/mcp.json`` is a copy-paste snippet for the local (stdio) MCP server; its ``env.NEXUS_BASE_URL`` is the CRM API base that server talks to. Left unchanged it would ship the source (production) base to a test/staging download, so it is rewritten at build time. Returns ``None`` when the snippet is absent. Serialized deterministically (key order preserved so the leading ``//`` help comment stays first). """ path = package_dir / "mcp" / "mcp.json" if not path.is_file(): return None config = json.loads(path.read_text()) for server in config.get("mcpServers", {}).values(): env = server.get("env") if isinstance(env, dict) and "NEXUS_BASE_URL" in env: env["NEXUS_BASE_URL"] = base_url rendered = (json.dumps(config, indent=2) + "\n").encode("utf-8") return path, rendered def _file_bytes(source: Path, overrides: dict[Path, bytes]) -> bytes: return overrides.get(source, source.read_bytes()) def write_cli_tarball( agent_tools_dir: Path, package_files: list[Path], out_path: Path, overrides: dict[Path, bytes] | None = None, ) -> None: overrides = overrides or {} package_dir = agent_tools_dir / PACKAGE_DIR_NAME install_sh = agent_tools_dir / "install.sh" if not install_sh.is_file(): raise BuildError(f"missing installer: {install_sh}") # (archive_name, source_path); install.sh sits at the extraction root so its # component discovery finds ./nexuscrm-connect/SKILL.md. members: list[tuple[str, Path]] = [("install.sh", install_sh)] members.extend( (f"{PACKAGE_DIR_NAME}/{file.relative_to(package_dir).as_posix()}", file) for file in package_files ) members.sort(key=lambda member: member[0]) buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w", format=tarfile.PAX_FORMAT) as tar: seen_dirs: set[str] = set() for archive_name, source in members: for parent in _parent_dirs(archive_name): if parent in seen_dirs: continue seen_dirs.add(parent) dir_info = tarfile.TarInfo(parent) dir_info.type = tarfile.DIRTYPE dir_info.mode = 0o755 dir_info.mtime = TAR_MTIME tar.addfile(dir_info) info = tarfile.TarInfo(archive_name) data = _file_bytes(source, overrides) info.size = len(data) info.mode = normalized_mode(source) info.mtime = TAR_MTIME tar.addfile(info, io.BytesIO(data)) with out_path.open("wb") as out_file: with gzip.GzipFile(fileobj=out_file, mode="wb", compresslevel=9, mtime=0) as gz: gz.write(buffer.getvalue()) def write_plugin_bundle( package_dir: Path, package_files: list[Path], out_path: Path, overrides: dict[Path, bytes] | None = None, ) -> None: overrides = overrides or {} with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as bundle: for file in package_files: archive_name = file.relative_to(package_dir).as_posix() info = zipfile.ZipInfo(archive_name, date_time=ZIP_DATE_TIME) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = (normalized_mode(file) | stat.S_IFREG) << 16 bundle.writestr(info, _file_bytes(file, overrides)) def _parent_dirs(archive_name: str) -> list[str]: parts = archive_name.split("/")[:-1] return ["/".join(parts[: index + 1]) for index in range(len(parts))] def sha256_of(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def build(agent_tools_dir: Path, out_dir: Path, mcp_url: str | None = None) -> dict[str, object]: package_dir = agent_tools_dir / PACKAGE_DIR_NAME validate_plugin_manifest(package_dir) version = package_version(package_dir) package_files = collect_files(package_dir) out_dir.mkdir(parents=True, exist_ok=True) # Rewrite every shipped MCP config to the deploying environment so the # artifact never points a test/staging download at prod. The plugin manifest no longer # carries a server to stamp, so --mcp-url now has exactly one consumer: the local-stdio # mcp/mcp.json snippet, which gets the base URL (endpoint minus the /mcp suffix). The flag # keeps its name and shape so deploy callers do not change. overrides: dict[Path, bytes] = {} if mcp_url is not None: if not mcp_url.startswith("https://"): raise BuildError(f"--mcp-url must be an https url, got {mcp_url!r}") base_url = mcp_url[: -len("/mcp")] if mcp_url.endswith("/mcp") else mcp_url local_registration = mcp_local_registration_override(package_dir, base_url) if local_registration is not None: overrides[local_registration[0]] = local_registration[1] cli_path = out_dir / CLI_ARTIFACT_FILENAME plugin_path = out_dir / PLUGIN_ARTIFACT_FILENAME write_cli_tarball(agent_tools_dir, package_files, cli_path, overrides) write_plugin_bundle(package_dir, package_files, plugin_path, overrides) return { "version": version, "cli": {"path": str(cli_path), "sha256": sha256_of(cli_path)}, "plugin": {"path": str(plugin_path), "sha256": sha256_of(plugin_path)}, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--out-dir", required=True, help="directory to write nexuscrm-connect-cli.tgz and nexuscrm-connect-plugin.dxt into", ) parser.add_argument( "--mcp-url", default=None, help=( "https URL of the deploying environment's hosted /mcp endpoint; its " "base (the URL minus /mcp) is rewritten into the shipped local-stdio " "mcp/mcp.json snippet so a test/staging artifact never points clients " "at production. The plugin manifest declares no server and is not " "stamped. When omitted the source snippet ships unchanged." ), ) args = parser.parse_args() agent_tools_dir = Path(__file__).resolve().parent try: summary = build( agent_tools_dir, Path(args.out_dir).expanduser().resolve(), mcp_url=args.mcp_url, ) except BuildError as exc: print(f"build_artifacts: {exc}", file=sys.stderr) return 1 print(json.dumps(summary, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())