| 1 | #!/usr/bin/env python3 |
| 2 | """Convert selected OpenCode or static DSH MCP data into a reviewable native plugin bundle. |
| 3 | |
| 4 | DeepSeek Harness bundle *packages* are imported natively: `/plugin import dsh <dir>` |
| 5 | (crates/tui/src/plugins/install/dsh.rs). This script no longer converts them. |
| 6 | |
| 7 | This is an offline authoring tool, not a foreign plugin runtime or installer. |
| 8 | The existing Codewhale /plugin install and hash-bound review remain authoritative. |
| 9 | Requires Python 3.10+ and PyYAML 6+; never loads upstream code or configuration. |
| 10 | """ |
| 11 | |
| 12 | import argparse |
| 13 | import hashlib |
| 14 | import ipaddress |
| 15 | import json |
| 16 | import os |
| 17 | from pathlib import Path |
| 18 | import re |
| 19 | import stat |
| 20 | import sys |
| 21 | from urllib.parse import urlsplit |
| 22 | |
| 23 | import yaml |
| 24 | |
| 25 | |
| 26 | MAX_FILES = 4096 |
| 27 | MAX_BYTES = 64 * 1024 * 1024 |
| 28 | MAX_DOCUMENT = 1024 * 1024 |
| 29 | # Recorded in every receipt so a reviewed conversion can be attributed to a converter revision. |
| 30 | CONVERTER_VERSION = "0.10.1" |
| 31 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?\Z") |
| 32 | |
| 33 | |
| 34 | class ConversionError(ValueError): |
| 35 | pass |
| 36 | |
| 37 | |
| 38 | def require(condition, message): |
| 39 | if not condition: |
| 40 | raise ConversionError(message) |
| 41 | |
| 42 | |
| 43 | def mapping(value, allowed=None): |
| 44 | require(isinstance(value, dict), "Expected a configuration object.") |
| 45 | require(all(isinstance(k, str) for k in value), "Object keys must be strings.") |
| 46 | if allowed is not None: |
| 47 | require(not value.keys() - set(allowed), "Unsupported fields; select only documented portable declarations.") |
| 48 | return value |
| 49 | |
| 50 | |
| 51 | def unique_pairs(pairs): |
| 52 | result = {} |
| 53 | for key, value in pairs: |
| 54 | require(isinstance(key, str) and key not in result, "Duplicate or non-string object key.") |
| 55 | result[key] = value |
| 56 | return result |
| 57 | |
| 58 | |
| 59 | class DataLoader(yaml.SafeLoader): |
| 60 | def construct_mapping(self, node, deep=False): |
| 61 | return unique_pairs((self.construct_object(k, deep=deep), self.construct_object(v, deep=deep)) |
| 62 | for k, v in node.value) |
| 63 | |
| 64 | |
| 65 | def data(text, *, json_only=False): |
| 66 | """Closed data parsing: no YAML aliases/tags, duplicate keys or JS expressions.""" |
| 67 | try: |
| 68 | if json_only: |
| 69 | value = json.loads(text, object_pairs_hook=unique_pairs, |
| 70 | parse_constant=lambda _: (_ for _ in ()).throw(ConversionError("Non-finite JSON number."))) |
| 71 | else: |
| 72 | depth = 0 |
| 73 | for event in yaml.parse(text): |
| 74 | tag = getattr(event, "tag", None) |
| 75 | if isinstance(event, yaml.AliasEvent): |
| 76 | raise ConversionError("YAML aliases are unsupported.") |
| 77 | if tag is not None: |
| 78 | raise ConversionError("YAML aliases and explicit tags (including !!js) are unsupported.") |
| 79 | if isinstance(event, (yaml.MappingStartEvent, yaml.SequenceStartEvent)): |
| 80 | depth += 1 |
| 81 | require(depth <= 32, "Configuration nesting exceeds 32 levels.") |
| 82 | elif isinstance(event, (yaml.MappingEndEvent, yaml.SequenceEndEvent)): |
| 83 | depth -= 1 |
| 84 | value = yaml.load(text, Loader=DataLoader) |
| 85 | check_data(value) |
| 86 | return value |
| 87 | except (yaml.YAMLError, json.JSONDecodeError, RecursionError, TypeError): |
| 88 | # Parser errors can contain source lines and credentials. Do not echo them. |
| 89 | raise ConversionError("Cannot parse portable data; use JSON for OpenCode or plain YAML/JSON for DSH.") from None |
| 90 | |
| 91 | |
| 92 | def check_data(value, depth=0): |
| 93 | require(depth <= 32, "Configuration nesting exceeds 32 levels.") |
| 94 | if isinstance(value, dict): |
| 95 | mapping(value) |
| 96 | require("__jsExpr" not in value, "DSH executable expressions require a manual port.") |
| 97 | for child in value.values(): |
| 98 | check_data(child, depth + 1) |
| 99 | elif isinstance(value, list): |
| 100 | for child in value: |
| 101 | check_data(child, depth + 1) |
| 102 | else: |
| 103 | require(value is None or type(value) in (str, int, float, bool), "Unsupported data type.") |
| 104 | |
| 105 | |
| 106 | def plain_path(path): |
| 107 | """Reject links/reparse points in the supplied path, including ancestors.""" |
| 108 | path = Path(os.path.abspath(path)) |
| 109 | for entry in (path, *path.parents): |
| 110 | try: |
| 111 | info = entry.lstat() |
| 112 | except FileNotFoundError: |
| 113 | continue |
| 114 | require(not stat.S_ISLNK(info.st_mode) |
| 115 | and not (getattr(info, "st_file_attributes", 0) & 0x400), |
| 116 | "Source and output paths must not contain links or reparse points.") |
| 117 | return path |
| 118 | |
| 119 | |
| 120 | def read_file(path, limit=MAX_DOCUMENT): |
| 121 | path = plain_path(path) |
| 122 | info = path.stat() |
| 123 | require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "Only regular, non-linked source files are supported.") |
| 124 | require(info.st_size <= limit, "Source file exceeds the conversion size limit.") |
| 125 | # O_NOFOLLOW protects the final component against replacement after lstat. |
| 126 | fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) |
| 127 | with os.fdopen(fd, "rb") as source: |
| 128 | opened = os.fstat(source.fileno()) |
| 129 | require((info.st_dev, info.st_ino) == (opened.st_dev, opened.st_ino), "Source changed during conversion.") |
| 130 | content = source.read(limit + 1) |
| 131 | require(len(content) <= limit, "Source file exceeds the conversion size limit.") |
| 132 | return content |
| 133 | |
| 134 | |
| 135 | def text_file(path): |
| 136 | return decode_config(read_file(path)) |
| 137 | |
| 138 | |
| 139 | def decode_config(content): |
| 140 | try: |
| 141 | return content.decode("utf-8") |
| 142 | except UnicodeError: |
| 143 | raise ConversionError("Configuration and skill entrypoints must be UTF-8.") from None |
| 144 | |
| 145 | |
| 146 | def skill_files(path, max_files=MAX_FILES, max_bytes=MAX_BYTES): |
| 147 | source = plain_path(path) |
| 148 | entry = source / "SKILL.md" if source.is_dir() else source |
| 149 | require(entry.name == "SKILL.md" or entry.suffix == ".md", "Select a skill directory or Markdown skill file.") |
| 150 | text = text_file(entry) |
| 151 | parts = re.split(r"^---\s*$", text, maxsplit=2, flags=re.MULTILINE) |
| 152 | require(len(parts) == 3 and not parts[0].strip(), "Skills need YAML frontmatter with name and description.") |
| 153 | meta = mapping(data(parts[1]), {"name", "description", "license", "compatibility", "metadata", |
| 154 | "disable-model-invocation", "user-invocable"}) |
| 155 | name = meta.get("name") |
| 156 | require(isinstance(name, str) and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) |
| 157 | and len(name) <= 64, "Skill name must be a kebab-case identifier of at most 64 characters.") |
| 158 | description = meta.get("description") |
| 159 | require(isinstance(description, str) and description.strip(), "Skills need a non-empty description.") |
| 160 | require("---" not in description, "Skill description contains a delimiter the native reader cannot preserve.") |
| 161 | require(type(meta.get("user-invocable", True)) is bool and meta.get("user-invocable", True), |
| 162 | "user-invocable:false has no equivalent in the native skill adapter; port it manually.") |
| 163 | explicit = meta.get("disable-model-invocation", False) |
| 164 | require(type(explicit) is bool, "disable-model-invocation must be a boolean.") |
| 165 | # Native parsing is deliberately simpler than YAML. Emit unambiguous core fields. |
| 166 | front = f"---\nname: {name}\ndescription: |-\n" |
| 167 | front += "\n".join(" " + line for line in description.splitlines()) + "\n" |
| 168 | if explicit: |
| 169 | front += "invocation: explicit-only\n" |
| 170 | generated = (front + "---" + parts[2]).encode("utf-8") |
| 171 | files = {f"skills/{name}/SKILL.md": generated} |
| 172 | extra = {key: meta[key] for key in ("license", "compatibility", "metadata") if key in meta} |
| 173 | if extra: |
| 174 | # Native frontmatter is a flat parser: nested metadata must not override |
| 175 | # its name/description/invocation. Preserve attribution as companion data. |
| 176 | files[f"skills/{name}/SOURCE_SKILL_METADATA.json"] = (json.dumps(extra, ensure_ascii=False, indent=2) + "\n").encode() |
| 177 | used_bytes = sum(map(len, files.values())) |
| 178 | require(len(files) <= max_files and used_bytes <= max_bytes, "Selected skills exceed the bundle budget.") |
| 179 | if source.is_dir(): |
| 180 | visited = 0 |
| 181 | for current, dirs, children in os.walk(source, followlinks=False): |
| 182 | for child in dirs + children: |
| 183 | visited += 1 |
| 184 | require(visited <= MAX_FILES, "Selected skill contains too many filesystem entries.") |
| 185 | candidate = plain_path(Path(current) / child) |
| 186 | require(candidate.name not in {".git", ".env", ".installed-from"}, |
| 187 | "Remove local secrets, repository metadata or install receipts from the selected skill.") |
| 188 | if candidate.is_dir() or candidate == entry: |
| 189 | continue |
| 190 | relative = f"skills/{name}/{candidate.relative_to(source).as_posix()}" |
| 191 | require(relative not in files, "Skill companion collides with generated metadata.") |
| 192 | require(len(files) < max_files, "Selected skills exceed the file budget.") |
| 193 | files[relative] = read_file(candidate, max_bytes - used_bytes) |
| 194 | used_bytes += len(files[relative]) |
| 195 | return name, files |
| 196 | |
| 197 | |
| 198 | def timeout(value): |
| 199 | require(type(value) is int and 1000 <= value <= 3600000 and value % 1000 == 0, |
| 200 | "Timeouts must be whole seconds expressed in milliseconds (1000–3600000); port other values manually.") |
| 201 | return value // 1000 |
| 202 | |
| 203 | |
| 204 | def server_options(config, dialect, defaults): |
| 205 | extension = {} |
| 206 | if dialect == "dsh": |
| 207 | require(config.get("failOnStartupError", False) is False, "DSH startup-failure policy requires a manual port.") |
| 208 | if "toolCallTimeoutMs" in config: |
| 209 | extension["execute_timeout"] = timeout(config["toolCallTimeoutMs"]) |
| 210 | else: |
| 211 | disabled = not config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False) |
| 212 | flag = config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False) |
| 213 | require(type(flag) is bool, "MCP enablement must be a boolean.") |
| 214 | extension["disabled"] = disabled |
| 215 | if dialect == "opencode-v1": |
| 216 | if "timeout" in config: |
| 217 | extension["connect_timeout"] = timeout(config["timeout"]) |
| 218 | extension["execute_timeout"] = timeout(config["timeout"]) |
| 219 | else: |
| 220 | limits = {**mapping(defaults, {"startup", "request"}), |
| 221 | **mapping(config.get("timeout", {}), {"startup", "request"})} |
| 222 | for old, new in (("startup", "connect_timeout"), ("request", "execute_timeout")): |
| 223 | if old in limits: |
| 224 | extension[new] = timeout(limits[old]) |
| 225 | return extension |
| 226 | |
| 227 | |
| 228 | def remote_server(config, dialect, defaults): |
| 229 | mapping(config) |
| 230 | if dialect == "dsh": |
| 231 | require(config.get("transport") == "streamable-http", "Unsupported MCP transport.") |
| 232 | mapping(config, {"serverName", "transport", "url", "headers", "toolCallTimeoutMs", "failOnStartupError"}) |
| 233 | else: |
| 234 | require(config.get("type") == "remote", "Unsupported MCP transport.") |
| 235 | mapping(config, {"type", "url", "headers", "oauth", "enabled" if dialect == "opencode-v1" else "disabled", "timeout"}) |
| 236 | require(config.get("oauth") is False, "Set oauth:false explicitly; plugin OAuth and upstream auto-OAuth cannot be converted.") |
| 237 | extension = server_options(config, dialect, defaults) |
| 238 | url = config.get("url") |
| 239 | require(isinstance(url, str) and not re.search(r"[\s\\{}]", url), "MCP URL must be a literal endpoint without interpolation.") |
| 240 | try: |
| 241 | parsed = urlsplit(url) |
| 242 | host = parsed.hostname |
| 243 | require(bool(host) and parsed.port != 0, "MCP URL needs a valid host and port.") |
| 244 | except ValueError: |
| 245 | raise ConversionError("Invalid MCP endpoint URL.") from None |
| 246 | require(parsed.scheme == "https" or (parsed.scheme == "http" and host in {"localhost", "127.0.0.1", "::1"}), |
| 247 | "MCP endpoints need HTTPS (or explicit loopback HTTP).") |
| 248 | require(parsed.username is None and parsed.password is None and not parsed.query and not parsed.fragment, |
| 249 | "MCP URLs must not contain credentials, query strings or fragments.") |
| 250 | require(host.isascii() and len(url) <= 4096, "Use an ASCII MCP hostname and URL of at most 4096 characters.") |
| 251 | # URL implementations disagree on shorthand/hex IPv4 spellings. Emit only |
| 252 | # canonical numeric hosts so the reviewed native host set is identical. |
| 253 | if ":" in host or re.fullmatch(r"(?:[0-9]+|0x[0-9a-f]+)", host.rsplit(".", 1)[-1]): |
| 254 | try: |
| 255 | address = ipaddress.ip_address(host) |
| 256 | require(str(address) == host, "Use a canonical numeric MCP address.") |
| 257 | host = f"[{host}]" if address.version == 6 else host |
| 258 | except ValueError: |
| 259 | raise ConversionError("Use a canonical numeric MCP address.") from None |
| 260 | headers = mapping(config.get("headers", {})) |
| 261 | require(len(headers) <= 64, "At most 64 environment-backed headers are supported.") |
| 262 | env_headers = {} |
| 263 | seen_headers = set() |
| 264 | for key, value in headers.items(): |
| 265 | require(re.fullmatch(r"[A-Za-z0-9!#$%&'*+.^_`|~-]+", key) is not None, "Invalid HTTP header name.") |
| 266 | require(key.lower() not in seen_headers and key.lower() not in {"accept", "content-type"}, |
| 267 | "HTTP header names must be unique ignoring case; Accept and Content-Type belong to the native transport.") |
| 268 | seen_headers.add(key.lower()) |
| 269 | require(isinstance(value, str), "HTTP headers must reference environment variable names.") |
| 270 | reference = re.fullmatch(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}", value) if dialect != "dsh" else None |
| 271 | require(reference is not None, "Literal headers, DSH expressions and file interpolation cannot be converted; author native env_headers manually.") |
| 272 | env_headers[key] = reference[1] |
| 273 | if env_headers: |
| 274 | extension["env_headers"] = env_headers |
| 275 | return {"type": "streamable-http", "url": url, |
| 276 | "extensions": {"net.codewhale": extension}}, host |
| 277 | |
| 278 | |
| 279 | def stdio_server(config, dialect, defaults, name, root): |
| 280 | require(root is not None, "Local MCP needs an explicit --stdio-root SERVER=DIRECTORY containing its packaged Node source.") |
| 281 | if dialect == "dsh": |
| 282 | mapping(config, {"serverName", "transport", "command", "args", "env", "cwd", "toolCallTimeoutMs", "failOnStartupError"}) |
| 283 | command, arguments = config.get("command"), config.get("args", []) |
| 284 | require(not mapping(config.get("env", {})), "DSH stdio env values and expressions require a manual native port.") |
| 285 | environment = {} |
| 286 | else: |
| 287 | allowed = {"type", "command", "environment", "timeout", "enabled" if dialect == "opencode-v1" else "disabled"} |
| 288 | if dialect == "opencode-v2": |
| 289 | allowed.add("cwd") |
| 290 | mapping(config, allowed) |
| 291 | argv = config.get("command") |
| 292 | require(isinstance(argv, list) and len(argv) == 2, "Local MCP command must be exactly [\"node\", \"relative-entry.js\"].") |
| 293 | command, arguments = argv[0], argv[1:] |
| 294 | environment = mapping(config.get("environment", {})) |
| 295 | require(command == "node" and isinstance(arguments, list) and len(arguments) == 1, |
| 296 | "Only node with one packaged .mjs, .js or .cjs entry is supported; no launcher flags, package managers or shell commands.") |
| 297 | entry = arguments[0] |
| 298 | require(isinstance(entry, str) and re.fullmatch(r"(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:mjs|js|cjs)", entry) |
| 299 | and ".." not in entry.split("/"), "Node entry must be a contained relative .mjs, .js or .cjs file; compile other entry formats before packaging.") |
| 300 | require(config.get("cwd", "") in ("", "."), "Select the original process working directory with --stdio-root; other cwd values require a manual port.") |
| 301 | require(plain_path(root / entry).is_file(), "Packaged Node entry does not exist.") |
| 302 | require(len(environment) <= 64, "At most 64 environment mappings are supported.") |
| 303 | env = {} |
| 304 | for key, value in environment.items(): |
| 305 | require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) is not None |
| 306 | and key.upper() not in {"PLUGIN_ROOT", "PLUGIN_DATA", "NODE_OPTIONS", "NODE_PATH", "PATH", |
| 307 | "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"}, |
| 308 | "Invalid, reserved or loader-changing stdio environment name.") |
| 309 | reference = re.fullmatch(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}", value) if isinstance(value, str) else None |
| 310 | require(reference is not None, "Local MCP environment values must be exact OpenCode {env:NAME} references; literals are not copied.") |
| 311 | env[key] = "${" + reference[1] + "}" |
| 312 | return {"type": "stdio", "command": "node", "args": [entry], "cwd": f"mcp/{name}", |
| 313 | "env": env, "extensions": {"net.codewhale": server_options(config, dialect, defaults)}} |
| 314 | |
| 315 | |
| 316 | def stdio_files(root, name, max_files, max_bytes): |
| 317 | """Copy an explicitly packaged directory, never resolve/install dependencies.""" |
| 318 | def fail_walk(error): |
| 319 | raise error |
| 320 | |
| 321 | files, visited, used_bytes = {}, 0, 0 |
| 322 | for current, dirs, children in os.walk(root, followlinks=False, onerror=fail_walk): |
| 323 | for child in dirs + children: |
| 324 | visited += 1 |
| 325 | require(visited <= MAX_FILES, "Packaged MCP source has too many filesystem entries.") |
| 326 | candidate = plain_path(Path(current) / child) |
| 327 | lower = candidate.name.lower() |
| 328 | require(not lower.startswith(".") and lower not in {"credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"} |
| 329 | and candidate.suffix.lower() not in {".pem", ".key", ".p12", ".pfx"}, |
| 330 | "Package MCP source without hidden files, repository metadata or credential files; nothing was copied.") |
| 331 | if candidate.is_dir(): |
| 332 | continue |
| 333 | require(len(files) < max_files, "Selected components exceed the file budget.") |
| 334 | content = read_file(candidate, max_bytes - used_bytes) |
| 335 | files[f"mcp/{name}/{candidate.relative_to(root).as_posix()}"] = content |
| 336 | used_bytes += len(content) |
| 337 | return files |
| 338 | |
| 339 | |
| 340 | def mcp_config(path, dialect, stdio_roots=None): |
| 341 | stdio_roots = stdio_roots or {} |
| 342 | document = data(text_file(path), json_only=dialect != "dsh") |
| 343 | ignored = 0 |
| 344 | if dialect == "dsh": |
| 345 | require(isinstance(document, list), "DSH input must be a plain Cordis entry list, not a profile or patch composition.") |
| 346 | entries = [] |
| 347 | for row in document: |
| 348 | mapping(row, {"name", "id", "config", "disabled"}) |
| 349 | require(row.get("name") == "@deepseek-ai/dsh-mcp-client", "DSH runtime plugins and patch operations require a manual port.") |
| 350 | require(type(row.get("disabled", False)) is bool, "DSH disabled must be a boolean.") |
| 351 | config = mapping(row.get("config")) |
| 352 | entries.append((config.get("serverName"), config, row.get("disabled", False))) |
| 353 | defaults = {} |
| 354 | else: |
| 355 | mapping(document) |
| 356 | require(not document.get("plugin") and not document.get("plugins"), "OpenCode executable plugins require a manual port; select portable data only.") |
| 357 | ignored = len(document.keys() - {"$schema", "mcp", "plugin", "plugins"}) |
| 358 | servers = mapping(document.get("mcp", {})) |
| 359 | defaults = {} |
| 360 | if dialect == "opencode-v2": |
| 361 | mapping(servers, {"servers", "timeout"}) |
| 362 | defaults = servers.get("timeout", {}) |
| 363 | servers = mapping(servers.get("servers", {})) |
| 364 | # These application fields govern MCP tool access, including legacy |
| 365 | # per-agent overrides. A server-level enabled flag cannot preserve them. |
| 366 | if servers: |
| 367 | require(not document.keys() & {"tools", "permission", "permissions", "agent", "agents", "mode", "default_agent"}, |
| 368 | "OpenCode tool permissions and agent policies require a manual port; preserve their restrictions in Codewhale before supplying MCP-only input.") |
| 369 | entries = [(key, value, False) for key, value in servers.items()] |
| 370 | require(len(entries) <= 64, "At most 64 MCP servers can be converted at once.") |
| 371 | result, hosts = {}, set() |
| 372 | local_names = set() |
| 373 | for name, config, disabled in entries: |
| 374 | require(isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,31}", name), "Invalid or missing MCP server name.") |
| 375 | require(name not in result, "Duplicate MCP server name; no entries were written.") |
| 376 | mapping(config) |
| 377 | local = config.get("transport") == "stdio" if dialect == "dsh" else config.get("type") == "local" |
| 378 | if local: |
| 379 | converted = stdio_server(config, dialect, defaults, name, stdio_roots.get(name)) |
| 380 | local_names.add(name) |
| 381 | else: |
| 382 | converted, host = remote_server(config, dialect, defaults) |
| 383 | hosts.add(host) |
| 384 | if disabled: |
| 385 | converted["extensions"]["net.codewhale"]["disabled"] = True |
| 386 | result[name] = converted |
| 387 | require(set(stdio_roots) == local_names, "Every --stdio-root must name a selected local MCP server.") |
| 388 | return result, sorted(hosts), ignored |
| 389 | |
| 390 | |
| 391 | def convert(args): |
| 392 | require(NAME.fullmatch(args.name) is not None and ".." not in args.name and "--" not in args.name, |
| 393 | "Choose a native plugin name: 1–64 lowercase letters/digits with single internal dots or hyphens.") |
| 394 | require(getattr(args, "bundle", None) is None, |
| 395 | "DeepSeek Harness bundle packages are imported natively: run `/plugin import dsh <package-dir>` " |
| 396 | "in Codewhale to review and install one. This script converts OpenCode and static DSH MCP data only.") |
| 397 | output = Path(os.path.abspath(args.output)) |
| 398 | plain_path(output.parent) |
| 399 | require(not os.path.lexists(output), "Output already exists; choose a fresh directory. Nothing was overwritten.") |
| 400 | files, skill_names = {}, set() |
| 401 | skill_sources = [plain_path(path) for path in args.skill] |
| 402 | servers, hosts, ignored = {}, [], 0 |
| 403 | roots = {} |
| 404 | for specification in getattr(args, "stdio_root", []): |
| 405 | name, separator, directory = specification.partition("=") |
| 406 | require(separator and directory and name not in roots, "Use unique --stdio-root SERVER=DIRECTORY selections.") |
| 407 | source = plain_path(directory) |
| 408 | require(source.is_dir(), "The selected stdio root must be a directory.") |
| 409 | require(source != output and source not in output.parents, "Output must be outside the selected MCP source.") |
| 410 | roots[name] = source |
| 411 | require(not roots or args.config, "--stdio-root requires a selected MCP configuration.") |
| 412 | servers, hosts, ignored = mcp_config(args.config, args.format, roots) if args.config else ({}, [], 0) |
| 413 | for source in skill_sources: |
| 414 | require(source != output and source not in output.parents, "Output must be outside the selected skill.") |
| 415 | name, additions = skill_files(source, MAX_FILES - len(files), MAX_BYTES - sum(map(len, files.values()))) |
| 416 | require(name not in skill_names, "Duplicate skill name; no files were written.") |
| 417 | skill_names.add(name) |
| 418 | files.update(additions) |
| 419 | for name, source in roots.items(): |
| 420 | files.update(stdio_files(source, name, MAX_FILES - len(files), MAX_BYTES - sum(map(len, files.values())))) |
| 421 | require(files or servers, "No portable components selected. Use --skill or --config.") |
| 422 | manifest = {"$schema": "https://agent-plugins.org/schemas/plugin.json", "name": args.name} |
| 423 | extension = {} |
| 424 | if hosts: |
| 425 | extension["capabilities"] = {"network_hosts": sorted(set(hosts))} |
| 426 | if roots: |
| 427 | extension["when"] = {"binaries": ["node"]} |
| 428 | if extension: |
| 429 | manifest["extensions"] = {"net.codewhale": extension} |
| 430 | files["plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() |
| 431 | if servers: |
| 432 | files["mcp.json"] = (json.dumps({"mcpServers": servers}, indent=2) + "\n").encode() |
| 433 | provenance = [f"Converter version {CONVERTER_VERSION}. Source dialect: {args.format}."] |
| 434 | lines = ["# Conversion receipt", "", *provenance, "", |
| 435 | f"Converted {len(skill_names)} selected Skills, {len(servers) - len(roots)} remote and " |
| 436 | f"{len(roots)} local MCP declarations.", |
| 437 | f"Ignored {ignored} unrelated top-level application settings.", ""] |
| 438 | lines += [ |
| 439 | "No source code, package manager, install hook, network request or credential lookup ran.", |
| 440 | "No environment variable values were resolved. Only the selected input roots were read;", |
| 441 | "host paths are never inferred from expressions or copied without an explicit source selection.", |
| 442 | "Companion skill files were copied as data; review them before loading a skill.", |
| 443 | "Selected Node source, dependencies and resources were copied as data into mcp/<server>.", |
| 444 | "Each --stdio-root is the original process working directory; the staged copy becomes its cwd.", |
| 445 | "Review all copied files and environment names. Node runs only through the native trusted MCP lifecycle.", |
| 446 | "Local MCP runs with host-user process authority, not an OS sandbox; stdio does not confine its network or files.", |
| 447 | "Mutable workspace state, writable package resources and external module dependencies require a manual port.", |
| 448 | "This output is not installed, trusted or enabled. Run `/plugin install <directory>`,", |
| 449 | "then `/plugin validate <name>` and review the exact trust token before enabling.", |
| 450 | "Remote MCP output uses Streamable HTTP only; OpenCode's legacy SSE fallback is not reproduced.", |
| 451 | "Conversion does not prove server connectivity or foreign runtime compatibility."] |
| 452 | files["CONVERSION.md"] = ("\n".join(lines) + "\n").encode() |
| 453 | require(len(files) <= MAX_FILES and sum(map(len, files.values())) <= MAX_BYTES, |
| 454 | "Output exceeds the 4096-file / 64 MiB bundle budget.") |
| 455 | # Complete all parsing before exclusive creation. Never install or alter a source tree. |
| 456 | output.mkdir(mode=0o700) |
| 457 | written = [] |
| 458 | try: |
| 459 | for relative, content in files.items(): |
| 460 | destination = output / relative |
| 461 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 462 | with destination.open("xb") as handle: |
| 463 | written.append(destination) |
| 464 | handle.write(content) |
| 465 | except OSError: |
| 466 | # Remove only files this operation created, never a pre-existing tree. |
| 467 | for destination in reversed(written): |
| 468 | destination.unlink(missing_ok=True) |
| 469 | for current, dirs, _ in os.walk(output, topdown=False): |
| 470 | for directory in dirs: |
| 471 | (Path(current) / directory).rmdir() |
| 472 | output.rmdir() |
| 473 | raise |
| 474 | return len(skill_names), len(servers), ignored |
| 475 | |
| 476 | |
| 477 | def main(): |
| 478 | parser = argparse.ArgumentParser(description=__doc__) |
| 479 | parser.add_argument("--format", choices=("opencode-v1", "opencode-v2", "dsh"), required=True) |
| 480 | parser.add_argument("--config", type=Path, help="OpenCode JSON or static DSH Cordis YAML/JSON (optional)") |
| 481 | parser.add_argument("--bundle", type=Path, |
| 482 | help="retired: import DeepSeek Harness bundle packages with `/plugin import dsh <dir>`") |
| 483 | parser.add_argument("--skill", type=Path, action="append", default=[], help="Explicit skill directory or Markdown file; repeatable") |
| 484 | parser.add_argument("--stdio-root", action="append", default=[], metavar="SERVER=DIRECTORY", |
| 485 | help="Explicit packaged Node MCP working directory; repeat for each selected local server") |
| 486 | parser.add_argument("--name", required=True, help="Name for the new native bundle") |
| 487 | parser.add_argument("--output", type=Path, required=True, help="Fresh directory whose parent already exists") |
| 488 | args = parser.parse_args() |
| 489 | try: |
| 490 | skills, servers, ignored = convert(args) |
| 491 | except (ConversionError, OSError, UnicodeError) as error: |
| 492 | message = str(error) if isinstance(error, ConversionError) else "File operation failed; source and output must be accessible regular paths." |
| 493 | print(f"Conversion refused: {message}", file=sys.stderr) |
| 494 | return 1 |
| 495 | print(f"Prepared {skills} Skills and {servers} MCP declarations; {ignored} unrelated settings omitted.") |
| 496 | print("Review CONVERSION.md, then use the existing /plugin install, validate, trust and enable commands.") |
| 497 | return 0 |
| 498 | |
| 499 | |
| 500 | if __name__ == "__main__": |
| 501 | sys.exit(main()) |
| 502 |