| 1 | #!/usr/bin/env python3 |
| 2 | """Convert selected OpenCode/DSH data — or a dsh bundle package — into a reviewable native plugin bundle. |
| 3 | |
| 4 | This is an offline authoring tool, not a foreign plugin runtime or installer. |
| 5 | The existing Codewhale /plugin install and hash-bound review remain authoritative. |
| 6 | Requires Python 3.10+ and PyYAML 6+; never loads upstream code or configuration. |
| 7 | """ |
| 8 | |
| 9 | import argparse |
| 10 | import ipaddress |
| 11 | import json |
| 12 | import os |
| 13 | from pathlib import Path |
| 14 | import re |
| 15 | import stat |
| 16 | import sys |
| 17 | from urllib.parse import urlsplit |
| 18 | |
| 19 | import yaml |
| 20 | |
| 21 | |
| 22 | MAX_FILES = 4096 |
| 23 | MAX_BYTES = 64 * 1024 * 1024 |
| 24 | MAX_DOCUMENT = 1024 * 1024 |
| 25 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?\Z") |
| 26 | |
| 27 | |
| 28 | class ConversionError(ValueError): |
| 29 | pass |
| 30 | |
| 31 | |
| 32 | class JsExpr(str): |
| 33 | """An unevaluated dsh `!!js` scalar captured for structural lowering; it is never executed.""" |
| 34 | |
| 35 | |
| 36 | def require(condition, message): |
| 37 | if not condition: |
| 38 | raise ConversionError(message) |
| 39 | |
| 40 | |
| 41 | def mapping(value, allowed=None): |
| 42 | require(isinstance(value, dict), "Expected a configuration object.") |
| 43 | require(all(isinstance(k, str) for k in value), "Object keys must be strings.") |
| 44 | if allowed is not None: |
| 45 | require(not value.keys() - set(allowed), "Unsupported fields; select only documented portable declarations.") |
| 46 | return value |
| 47 | |
| 48 | |
| 49 | def unique_pairs(pairs): |
| 50 | result = {} |
| 51 | for key, value in pairs: |
| 52 | require(isinstance(key, str) and key not in result, "Duplicate or non-string object key.") |
| 53 | result[key] = value |
| 54 | return result |
| 55 | |
| 56 | |
| 57 | class DataLoader(yaml.SafeLoader): |
| 58 | def construct_mapping(self, node, deep=False): |
| 59 | return unique_pairs((self.construct_object(k, deep=deep), self.construct_object(v, deep=deep)) |
| 60 | for k, v in node.value) |
| 61 | |
| 62 | |
| 63 | def js_scalar(loader, node): |
| 64 | return JsExpr(loader.construct_scalar(node)) |
| 65 | |
| 66 | |
| 67 | DataLoader.add_constructor("tag:yaml.org,2002:js", js_scalar) |
| 68 | |
| 69 | |
| 70 | def data(text, *, json_only=False, allow_js=False): |
| 71 | """Closed data parsing: no YAML aliases/tags, duplicate keys or JS expressions.""" |
| 72 | try: |
| 73 | if json_only: |
| 74 | value = json.loads(text, object_pairs_hook=unique_pairs, |
| 75 | parse_constant=lambda _: (_ for _ in ()).throw(ConversionError("Non-finite JSON number."))) |
| 76 | else: |
| 77 | depth = 0 |
| 78 | for event in yaml.parse(text): |
| 79 | tag = getattr(event, "tag", None) |
| 80 | if isinstance(event, yaml.AliasEvent): |
| 81 | raise ConversionError("YAML aliases are unsupported.") |
| 82 | if tag is not None and not (allow_js and isinstance(event, yaml.ScalarEvent) |
| 83 | and tag == "tag:yaml.org,2002:js"): |
| 84 | raise ConversionError("YAML aliases and explicit tags (including !!js) are unsupported.") |
| 85 | if isinstance(event, (yaml.MappingStartEvent, yaml.SequenceStartEvent)): |
| 86 | depth += 1 |
| 87 | require(depth <= 32, "Configuration nesting exceeds 32 levels.") |
| 88 | elif isinstance(event, (yaml.MappingEndEvent, yaml.SequenceEndEvent)): |
| 89 | depth -= 1 |
| 90 | value = yaml.load(text, Loader=DataLoader) |
| 91 | check_data(value, allow_js=allow_js) |
| 92 | return value |
| 93 | except (yaml.YAMLError, json.JSONDecodeError, RecursionError, TypeError): |
| 94 | # Parser errors can contain source lines and credentials. Do not echo them. |
| 95 | raise ConversionError("Cannot parse portable data; use JSON for OpenCode or plain YAML/JSON for DSH.") from None |
| 96 | |
| 97 | |
| 98 | def check_data(value, depth=0, allow_js=False): |
| 99 | require(depth <= 32, "Configuration nesting exceeds 32 levels.") |
| 100 | if isinstance(value, dict): |
| 101 | mapping(value) |
| 102 | require("__jsExpr" not in value, "DSH executable expressions require a manual port.") |
| 103 | for child in value.values(): |
| 104 | check_data(child, depth + 1, allow_js) |
| 105 | elif isinstance(value, list): |
| 106 | for child in value: |
| 107 | check_data(child, depth + 1, allow_js) |
| 108 | else: |
| 109 | require(value is None or type(value) in (str, int, float, bool) |
| 110 | or (allow_js and isinstance(value, JsExpr)), "Unsupported data type.") |
| 111 | |
| 112 | |
| 113 | def plain_path(path): |
| 114 | """Reject links/reparse points in the supplied path, including ancestors.""" |
| 115 | path = Path(os.path.abspath(path)) |
| 116 | for entry in (path, *path.parents): |
| 117 | try: |
| 118 | info = entry.lstat() |
| 119 | except FileNotFoundError: |
| 120 | continue |
| 121 | require(not stat.S_ISLNK(info.st_mode) |
| 122 | and not (getattr(info, "st_file_attributes", 0) & 0x400), |
| 123 | "Source and output paths must not contain links or reparse points.") |
| 124 | return path |
| 125 | |
| 126 | |
| 127 | def read_file(path, limit=MAX_DOCUMENT): |
| 128 | path = plain_path(path) |
| 129 | info = path.stat() |
| 130 | require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "Only regular, non-linked source files are supported.") |
| 131 | require(info.st_size <= limit, "Source file exceeds the conversion size limit.") |
| 132 | # O_NOFOLLOW protects the final component against replacement after lstat. |
| 133 | fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) |
| 134 | with os.fdopen(fd, "rb") as source: |
| 135 | opened = os.fstat(source.fileno()) |
| 136 | require((info.st_dev, info.st_ino) == (opened.st_dev, opened.st_ino), "Source changed during conversion.") |
| 137 | content = source.read(limit + 1) |
| 138 | require(len(content) <= limit, "Source file exceeds the conversion size limit.") |
| 139 | return content |
| 140 | |
| 141 | |
| 142 | def text_file(path): |
| 143 | try: |
| 144 | return read_file(path).decode("utf-8") |
| 145 | except UnicodeError: |
| 146 | raise ConversionError("Configuration and skill entrypoints must be UTF-8.") from None |
| 147 | |
| 148 | |
| 149 | def skill_files(path, max_files=MAX_FILES, max_bytes=MAX_BYTES): |
| 150 | source = plain_path(path) |
| 151 | entry = source / "SKILL.md" if source.is_dir() else source |
| 152 | require(entry.name == "SKILL.md" or entry.suffix == ".md", "Select a skill directory or Markdown skill file.") |
| 153 | text = text_file(entry) |
| 154 | parts = re.split(r"^---\s*$", text, maxsplit=2, flags=re.MULTILINE) |
| 155 | require(len(parts) == 3 and not parts[0].strip(), "Skills need YAML frontmatter with name and description.") |
| 156 | meta = mapping(data(parts[1]), {"name", "description", "license", "compatibility", "metadata", |
| 157 | "disable-model-invocation", "user-invocable"}) |
| 158 | name = meta.get("name") |
| 159 | require(isinstance(name, str) and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) |
| 160 | and len(name) <= 64, "Skill name must be a kebab-case identifier of at most 64 characters.") |
| 161 | description = meta.get("description") |
| 162 | require(isinstance(description, str) and description.strip(), "Skills need a non-empty description.") |
| 163 | require("---" not in description, "Skill description contains a delimiter the native reader cannot preserve.") |
| 164 | require(type(meta.get("user-invocable", True)) is bool and meta.get("user-invocable", True), |
| 165 | "user-invocable:false has no equivalent in the native skill adapter; port it manually.") |
| 166 | explicit = meta.get("disable-model-invocation", False) |
| 167 | require(type(explicit) is bool, "disable-model-invocation must be a boolean.") |
| 168 | # Native parsing is deliberately simpler than YAML. Emit unambiguous core fields. |
| 169 | front = f"---\nname: {name}\ndescription: |-\n" |
| 170 | front += "\n".join(" " + line for line in description.splitlines()) + "\n" |
| 171 | if explicit: |
| 172 | front += "invocation: explicit-only\n" |
| 173 | generated = (front + "---" + parts[2]).encode("utf-8") |
| 174 | files = {f"skills/{name}/SKILL.md": generated} |
| 175 | extra = {key: meta[key] for key in ("license", "compatibility", "metadata") if key in meta} |
| 176 | if extra: |
| 177 | # Native frontmatter is a flat parser: nested metadata must not override |
| 178 | # its name/description/invocation. Preserve attribution as companion data. |
| 179 | files[f"skills/{name}/SOURCE_SKILL_METADATA.json"] = (json.dumps(extra, ensure_ascii=False, indent=2) + "\n").encode() |
| 180 | used_bytes = sum(map(len, files.values())) |
| 181 | require(len(files) <= max_files and used_bytes <= max_bytes, "Selected skills exceed the bundle budget.") |
| 182 | if source.is_dir(): |
| 183 | visited = 0 |
| 184 | for current, dirs, children in os.walk(source, followlinks=False): |
| 185 | for child in dirs + children: |
| 186 | visited += 1 |
| 187 | require(visited <= MAX_FILES, "Selected skill contains too many filesystem entries.") |
| 188 | candidate = plain_path(Path(current) / child) |
| 189 | require(candidate.name not in {".git", ".env", ".installed-from"}, |
| 190 | "Remove local secrets, repository metadata or install receipts from the selected skill.") |
| 191 | if candidate.is_dir() or candidate == entry: |
| 192 | continue |
| 193 | relative = f"skills/{name}/{candidate.relative_to(source).as_posix()}" |
| 194 | require(relative not in files, "Skill companion collides with generated metadata.") |
| 195 | require(len(files) < max_files, "Selected skills exceed the file budget.") |
| 196 | files[relative] = read_file(candidate, max_bytes - used_bytes) |
| 197 | used_bytes += len(files[relative]) |
| 198 | return name, files |
| 199 | |
| 200 | |
| 201 | def timeout(value): |
| 202 | require(type(value) is int and 1000 <= value <= 3600000 and value % 1000 == 0, |
| 203 | "Timeouts must be whole seconds expressed in milliseconds (1000–3600000); port other values manually.") |
| 204 | return value // 1000 |
| 205 | |
| 206 | |
| 207 | def server_options(config, dialect, defaults): |
| 208 | extension = {} |
| 209 | if dialect == "dsh": |
| 210 | require(config.get("failOnStartupError", False) is False, "DSH startup-failure policy requires a manual port.") |
| 211 | if "toolCallTimeoutMs" in config: |
| 212 | extension["execute_timeout"] = timeout(config["toolCallTimeoutMs"]) |
| 213 | else: |
| 214 | disabled = not config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False) |
| 215 | flag = config.get("enabled", True) if dialect == "opencode-v1" else config.get("disabled", False) |
| 216 | require(type(flag) is bool, "MCP enablement must be a boolean.") |
| 217 | extension["disabled"] = disabled |
| 218 | if dialect == "opencode-v1": |
| 219 | if "timeout" in config: |
| 220 | extension["connect_timeout"] = timeout(config["timeout"]) |
| 221 | extension["execute_timeout"] = timeout(config["timeout"]) |
| 222 | else: |
| 223 | limits = {**mapping(defaults, {"startup", "request"}), |
| 224 | **mapping(config.get("timeout", {}), {"startup", "request"})} |
| 225 | for old, new in (("startup", "connect_timeout"), ("request", "execute_timeout")): |
| 226 | if old in limits: |
| 227 | extension[new] = timeout(limits[old]) |
| 228 | return extension |
| 229 | |
| 230 | |
| 231 | def remote_server(config, dialect, defaults): |
| 232 | mapping(config) |
| 233 | if dialect == "dsh": |
| 234 | require(config.get("transport") == "streamable-http", "Unsupported MCP transport.") |
| 235 | mapping(config, {"serverName", "transport", "url", "headers", "toolCallTimeoutMs", "failOnStartupError"}) |
| 236 | else: |
| 237 | require(config.get("type") == "remote", "Unsupported MCP transport.") |
| 238 | mapping(config, {"type", "url", "headers", "oauth", "enabled" if dialect == "opencode-v1" else "disabled", "timeout"}) |
| 239 | require(config.get("oauth") is False, "Set oauth:false explicitly; plugin OAuth and upstream auto-OAuth cannot be converted.") |
| 240 | extension = server_options(config, dialect, defaults) |
| 241 | url = config.get("url") |
| 242 | require(isinstance(url, str) and not re.search(r"[\s\\{}]", url), "MCP URL must be a literal endpoint without interpolation.") |
| 243 | try: |
| 244 | parsed = urlsplit(url) |
| 245 | host = parsed.hostname |
| 246 | require(bool(host) and parsed.port != 0, "MCP URL needs a valid host and port.") |
| 247 | except ValueError: |
| 248 | raise ConversionError("Invalid MCP endpoint URL.") from None |
| 249 | require(parsed.scheme == "https" or (parsed.scheme == "http" and host in {"localhost", "127.0.0.1", "::1"}), |
| 250 | "MCP endpoints need HTTPS (or explicit loopback HTTP).") |
| 251 | require(parsed.username is None and parsed.password is None and not parsed.query and not parsed.fragment, |
| 252 | "MCP URLs must not contain credentials, query strings or fragments.") |
| 253 | require(host.isascii() and len(url) <= 4096, "Use an ASCII MCP hostname and URL of at most 4096 characters.") |
| 254 | # URL implementations disagree on shorthand/hex IPv4 spellings. Emit only |
| 255 | # canonical numeric hosts so the reviewed native host set is identical. |
| 256 | if ":" in host or re.fullmatch(r"(?:[0-9]+|0x[0-9a-f]+)", host.rsplit(".", 1)[-1]): |
| 257 | try: |
| 258 | address = ipaddress.ip_address(host) |
| 259 | require(str(address) == host, "Use a canonical numeric MCP address.") |
| 260 | host = f"[{host}]" if address.version == 6 else host |
| 261 | except ValueError: |
| 262 | raise ConversionError("Use a canonical numeric MCP address.") from None |
| 263 | headers = mapping(config.get("headers", {})) |
| 264 | require(len(headers) <= 64, "At most 64 environment-backed headers are supported.") |
| 265 | env_headers = {} |
| 266 | seen_headers = set() |
| 267 | for key, value in headers.items(): |
| 268 | require(re.fullmatch(r"[A-Za-z0-9!#$%&'*+.^_`|~-]+", key) is not None, "Invalid HTTP header name.") |
| 269 | require(key.lower() not in seen_headers and key.lower() not in {"accept", "content-type"}, |
| 270 | "HTTP header names must be unique ignoring case; Accept and Content-Type belong to the native transport.") |
| 271 | seen_headers.add(key.lower()) |
| 272 | require(isinstance(value, str), "HTTP headers must reference environment variable names.") |
| 273 | reference = re.fullmatch(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}", value) if dialect != "dsh" else None |
| 274 | require(reference is not None, "Literal headers, DSH expressions and file interpolation cannot be converted; author native env_headers manually.") |
| 275 | env_headers[key] = reference[1] |
| 276 | if env_headers: |
| 277 | extension["env_headers"] = env_headers |
| 278 | return {"type": "streamable-http", "url": url, |
| 279 | "extensions": {"net.codewhale": extension}}, host |
| 280 | |
| 281 | |
| 282 | def stdio_server(config, dialect, defaults, name, root): |
| 283 | require(root is not None, "Local MCP needs an explicit --stdio-root SERVER=DIRECTORY containing its packaged Node source.") |
| 284 | if dialect == "dsh": |
| 285 | mapping(config, {"serverName", "transport", "command", "args", "env", "cwd", "toolCallTimeoutMs", "failOnStartupError"}) |
| 286 | command, arguments = config.get("command"), config.get("args", []) |
| 287 | require(not mapping(config.get("env", {})), "DSH stdio env values and expressions require a manual native port.") |
| 288 | environment = {} |
| 289 | else: |
| 290 | allowed = {"type", "command", "environment", "timeout", "enabled" if dialect == "opencode-v1" else "disabled"} |
| 291 | if dialect == "opencode-v2": |
| 292 | allowed.add("cwd") |
| 293 | mapping(config, allowed) |
| 294 | argv = config.get("command") |
| 295 | require(isinstance(argv, list) and len(argv) == 2, "Local MCP command must be exactly [\"node\", \"relative-entry.js\"].") |
| 296 | command, arguments = argv[0], argv[1:] |
| 297 | environment = mapping(config.get("environment", {})) |
| 298 | require(command == "node" and isinstance(arguments, list) and len(arguments) == 1, |
| 299 | "Only node with one packaged .mjs, .js or .cjs entry is supported; no launcher flags, package managers or shell commands.") |
| 300 | entry = arguments[0] |
| 301 | require(isinstance(entry, str) and re.fullmatch(r"(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:mjs|js|cjs)", entry) |
| 302 | and ".." not in entry.split("/"), "Node entry must be a contained relative .mjs, .js or .cjs file; compile other entry formats before packaging.") |
| 303 | require(config.get("cwd", "") in ("", "."), "Select the original process working directory with --stdio-root; other cwd values require a manual port.") |
| 304 | require(plain_path(root / entry).is_file(), "Packaged Node entry does not exist.") |
| 305 | require(len(environment) <= 64, "At most 64 environment mappings are supported.") |
| 306 | env = {} |
| 307 | for key, value in environment.items(): |
| 308 | require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) is not None |
| 309 | and key.upper() not in {"PLUGIN_ROOT", "PLUGIN_DATA", "NODE_OPTIONS", "NODE_PATH", "PATH", |
| 310 | "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"}, |
| 311 | "Invalid, reserved or loader-changing stdio environment name.") |
| 312 | reference = re.fullmatch(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}", value) if isinstance(value, str) else None |
| 313 | require(reference is not None, "Local MCP environment values must be exact OpenCode {env:NAME} references; literals are not copied.") |
| 314 | env[key] = "${" + reference[1] + "}" |
| 315 | return {"type": "stdio", "command": "node", "args": [entry], "cwd": f"mcp/{name}", |
| 316 | "env": env, "extensions": {"net.codewhale": server_options(config, dialect, defaults)}} |
| 317 | |
| 318 | |
| 319 | def stdio_files(root, name, max_files, max_bytes): |
| 320 | """Copy an explicitly packaged directory, never resolve/install dependencies.""" |
| 321 | def fail_walk(error): |
| 322 | raise error |
| 323 | |
| 324 | files, visited, used_bytes = {}, 0, 0 |
| 325 | for current, dirs, children in os.walk(root, followlinks=False, onerror=fail_walk): |
| 326 | for child in dirs + children: |
| 327 | visited += 1 |
| 328 | require(visited <= MAX_FILES, "Packaged MCP source has too many filesystem entries.") |
| 329 | candidate = plain_path(Path(current) / child) |
| 330 | lower = candidate.name.lower() |
| 331 | require(not lower.startswith(".") and lower not in {"credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"} |
| 332 | and candidate.suffix.lower() not in {".pem", ".key", ".p12", ".pfx"}, |
| 333 | "Package MCP source without hidden files, repository metadata or credential files; nothing was copied.") |
| 334 | if candidate.is_dir(): |
| 335 | continue |
| 336 | require(len(files) < max_files, "Selected components exceed the file budget.") |
| 337 | content = read_file(candidate, max_bytes - used_bytes) |
| 338 | files[f"mcp/{name}/{candidate.relative_to(root).as_posix()}"] = content |
| 339 | used_bytes += len(content) |
| 340 | return files |
| 341 | |
| 342 | |
| 343 | def mcp_config(path, dialect, stdio_roots=None): |
| 344 | stdio_roots = stdio_roots or {} |
| 345 | document = data(text_file(path), json_only=dialect != "dsh") |
| 346 | ignored = 0 |
| 347 | if dialect == "dsh": |
| 348 | require(isinstance(document, list), "DSH input must be a plain Cordis entry list, not a profile or patch composition.") |
| 349 | entries = [] |
| 350 | for row in document: |
| 351 | mapping(row, {"name", "id", "config", "disabled"}) |
| 352 | require(row.get("name") == "@deepseek-ai/dsh-mcp-client", "DSH runtime plugins and patch operations require a manual port.") |
| 353 | require(type(row.get("disabled", False)) is bool, "DSH disabled must be a boolean.") |
| 354 | config = mapping(row.get("config")) |
| 355 | entries.append((config.get("serverName"), config, row.get("disabled", False))) |
| 356 | defaults = {} |
| 357 | else: |
| 358 | mapping(document) |
| 359 | require(not document.get("plugin") and not document.get("plugins"), "OpenCode executable plugins require a manual port; select portable data only.") |
| 360 | ignored = len(document.keys() - {"$schema", "mcp", "plugin", "plugins"}) |
| 361 | servers = mapping(document.get("mcp", {})) |
| 362 | defaults = {} |
| 363 | if dialect == "opencode-v2": |
| 364 | mapping(servers, {"servers", "timeout"}) |
| 365 | defaults = servers.get("timeout", {}) |
| 366 | servers = mapping(servers.get("servers", {})) |
| 367 | # These application fields govern MCP tool access, including legacy |
| 368 | # per-agent overrides. A server-level enabled flag cannot preserve them. |
| 369 | if servers: |
| 370 | require(not document.keys() & {"tools", "permission", "permissions", "agent", "agents", "mode", "default_agent"}, |
| 371 | "OpenCode tool permissions and agent policies require a manual port; preserve their restrictions in Codewhale before supplying MCP-only input.") |
| 372 | entries = [(key, value, False) for key, value in servers.items()] |
| 373 | require(len(entries) <= 64, "At most 64 MCP servers can be converted at once.") |
| 374 | result, hosts = {}, set() |
| 375 | local_names = set() |
| 376 | for name, config, disabled in entries: |
| 377 | 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.") |
| 378 | require(name not in result, "Duplicate MCP server name; no entries were written.") |
| 379 | mapping(config) |
| 380 | local = config.get("transport") == "stdio" if dialect == "dsh" else config.get("type") == "local" |
| 381 | if local: |
| 382 | converted = stdio_server(config, dialect, defaults, name, stdio_roots.get(name)) |
| 383 | local_names.add(name) |
| 384 | else: |
| 385 | converted, host = remote_server(config, dialect, defaults) |
| 386 | hosts.add(host) |
| 387 | if disabled: |
| 388 | converted["extensions"]["net.codewhale"]["disabled"] = True |
| 389 | result[name] = converted |
| 390 | require(set(stdio_roots) == local_names, "Every --stdio-root must name a selected local MCP server.") |
| 391 | return result, sorted(hosts), ignored |
| 392 | |
| 393 | |
| 394 | DSH_MCP_CLIENT = "@deepseek-ai/dsh-mcp-client" |
| 395 | DSH_SKILL_FILESYSTEM = "@deepseek-ai/dsh-skill-filesystem" |
| 396 | DSH_ENTRY = re.compile(r"(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:mjs|js|cjs)") |
| 397 | DSH_ENV_NAME = r"[A-Za-z_][A-Za-z0-9_]*" |
| 398 | |
| 399 | |
| 400 | def js_literal(text, label): |
| 401 | """Resolve a quoted string or template literal inside a `!!js` idiom; env references |
| 402 | resolve from this machine because a bundle cannot name the original author's value.""" |
| 403 | text = text.strip() |
| 404 | if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0] and text[0] not in text[1:-1]: |
| 405 | return text[1:-1] |
| 406 | if text.startswith("`") and text.endswith("`") and len(text) >= 2: |
| 407 | body = text[1:-1] |
| 408 | resolved = re.sub(r"\$\{\s*process\.env\.(" + DSH_ENV_NAME + r")\s*\}", |
| 409 | lambda match: os.environ.get(match[1], ""), body) |
| 410 | missing = [name for name in re.findall(r"\$\{\s*process\.env\.(" + DSH_ENV_NAME + r")\s*\}", body) |
| 411 | if name not in os.environ] |
| 412 | if missing: |
| 413 | raise ConversionError(f"{label} references unset environment variable {missing[0]}.") |
| 414 | require("${" not in resolved, f"{label} interpolates an expression with no portable lowering.") |
| 415 | return resolved |
| 416 | raise ConversionError(f"{label} is not a quoted or template literal; author the value explicitly.") |
| 417 | |
| 418 | |
| 419 | def lower_js(value, label): |
| 420 | """Lower the documented `!!js` idioms to a literal string; every other expression refuses.""" |
| 421 | text = str(value).strip() |
| 422 | if text == "process.execPath": |
| 423 | return "node" |
| 424 | match = re.fullmatch(r"process\.env\.(" + DSH_ENV_NAME + r")", text) |
| 425 | if match: |
| 426 | resolved = os.environ.get(match[1]) |
| 427 | require(resolved is not None, f"{label} references environment variable {match[1]}, which is not set here.") |
| 428 | return resolved |
| 429 | match = re.fullmatch(r"process\.env\.(" + DSH_ENV_NAME + r")\s*\|\|\s*(.+)", text, re.DOTALL) |
| 430 | if match: |
| 431 | resolved = os.environ.get(match[1]) |
| 432 | if resolved is not None: |
| 433 | return resolved |
| 434 | return js_literal(match[2], f"{label} fallback") |
| 435 | if text.startswith("`"): |
| 436 | return js_literal(text, label) |
| 437 | raise ConversionError(f"{label} uses a `!!js` expression with no portable lowering; author the value explicitly.") |
| 438 | |
| 439 | |
| 440 | def free_of_js(value): |
| 441 | """True when no unevaluated `!!js` scalar survives inside the value.""" |
| 442 | if isinstance(value, JsExpr): |
| 443 | return False |
| 444 | if isinstance(value, dict): |
| 445 | return all(free_of_js(child) for child in value.values()) |
| 446 | if isinstance(value, list): |
| 447 | return all(free_of_js(child) for child in value) |
| 448 | return True |
| 449 | |
| 450 | |
| 451 | def evaluate_patches(patches, notes): |
| 452 | """Apply a dsh bundle patch list over an empty entry list (applyEntryPatches parity): |
| 453 | `insert` appends rows or appends into a group entry's config, keyed overrides |
| 454 | replace fields on an earlier inserted row. Skipped patches are recorded, never fatal.""" |
| 455 | entries, index = [], {} |
| 456 | def build_map(rows): |
| 457 | for row in rows: |
| 458 | if not isinstance(row, dict): |
| 459 | continue |
| 460 | identifier = row.get("id") |
| 461 | if isinstance(identifier, str): |
| 462 | index[identifier] = row |
| 463 | config = row.get("config") |
| 464 | if row.get("group") is True and isinstance(config, list): |
| 465 | build_map(config) |
| 466 | for order, patch in enumerate(patches): |
| 467 | require(isinstance(patch, dict), "Each dsh patch must be an object.") |
| 468 | insert, identifier = patch.get("insert"), patch.get("id") |
| 469 | if insert is not None: |
| 470 | require(isinstance(insert, list) and all(isinstance(row, dict) for row in insert), |
| 471 | "A dsh patch `insert` must be a list of entries.") |
| 472 | if identifier is None: |
| 473 | entries.extend(insert) |
| 474 | else: |
| 475 | target = index.get(identifier) |
| 476 | if target is None or target.get("group") is not True: |
| 477 | notes.append(f"patch {order + 1}: insert target `{identifier}` is missing or not a group; skipped") |
| 478 | continue |
| 479 | if not isinstance(target.get("config"), list): |
| 480 | target["config"] = [] |
| 481 | target["config"].extend(insert) |
| 482 | build_map(insert) |
| 483 | continue |
| 484 | if not isinstance(identifier, str): |
| 485 | notes.append(f"patch {order + 1}: non-insert patch without an `id`; skipped") |
| 486 | continue |
| 487 | target = index.get(identifier) |
| 488 | if target is None: |
| 489 | notes.append(f"patch {order + 1}: entry `{identifier}` was not inserted by an earlier layer; skipped") |
| 490 | continue |
| 491 | name = patch.get("name") |
| 492 | if name is not None and name != target.get("name"): |
| 493 | notes.append(f"patch {order + 1}: `name` does not match entry `{identifier}`; skipped") |
| 494 | continue |
| 495 | for key, value in patch.items(): |
| 496 | if key not in ("id", "name"): |
| 497 | target[key] = value |
| 498 | return entries |
| 499 | |
| 500 | |
| 501 | def load_dsh_bundle(path): |
| 502 | """Read a dsh bundle package directory: package.json → dsh.bundle.patch → evaluated rows.""" |
| 503 | bundle = plain_path(path) |
| 504 | require(bundle.is_dir(), "Select a dsh bundle package directory (a directory containing package.json).") |
| 505 | manifest = data(text_file(bundle / "package.json"), json_only=True) |
| 506 | mapping(manifest) |
| 507 | dsh = manifest.get("dsh") |
| 508 | require(isinstance(dsh, dict) and isinstance(dsh.get("bundle"), dict), |
| 509 | "Not a dsh bundle package: package.json lacks `dsh.bundle.patch`.") |
| 510 | notes = [] |
| 511 | if dsh.get("client") is not None: |
| 512 | notes.append("package declares `dsh.client`; the client UI half has no Codewhale equivalent and was not converted") |
| 513 | patch_rel = dsh["bundle"].get("patch") |
| 514 | require(isinstance(patch_rel, str) and bool(patch_rel), "`dsh.bundle.patch` must name a patch file.") |
| 515 | patch_path = plain_path(bundle / patch_rel) |
| 516 | require(patch_path.is_relative_to(bundle) and patch_path.is_file(), |
| 517 | "`dsh.bundle.patch` must resolve to a file inside the bundle directory.") |
| 518 | patches = data(text_file(patch_path), allow_js=True) |
| 519 | require(isinstance(patches, list), "A dsh bundle patch must be a patch list.") |
| 520 | return manifest, evaluate_patches(patches, notes), notes |
| 521 | |
| 522 | |
| 523 | def dsh_bundle_components(entries, bundle, explicit_roots): |
| 524 | """Convert evaluated dsh entries into Codewhale servers + skill sources. |
| 525 | Unconvertible rows are recorded as skipped diagnostics, never silently dropped.""" |
| 526 | servers, hosts, notes = {}, [], [] |
| 527 | implicit_roots = {} |
| 528 | skill_dirs = [] |
| 529 | |
| 530 | def label(row): |
| 531 | identifier = row.get("id") |
| 532 | return f"`{identifier}`" if isinstance(identifier, str) else "an unlabeled row" |
| 533 | |
| 534 | def mcp_row(row): |
| 535 | config = mapping(row.get("config")) |
| 536 | name = config.get("serverName") |
| 537 | require(isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,31}", name), |
| 538 | "dsh-mcp-client config needs a literal `serverName` of 1–32 letters/digits/_/-") |
| 539 | require(name not in servers, f"Duplicate MCP server name `{name}`; nothing was written for it.") |
| 540 | disabled = row.get("disabled", False) |
| 541 | require(type(disabled) is bool, "`disabled` must be a boolean; conditional rows need a manual port") |
| 542 | for field in ("command", "cwd", "url", "serverName"): |
| 543 | if isinstance(config.get(field), JsExpr): |
| 544 | config[field] = lower_js(config[field], f"`{field}` in `{name}`") |
| 545 | if isinstance(config.get("args"), list): |
| 546 | config["args"] = [lower_js(item, f"`args` in `{name}`") if isinstance(item, JsExpr) else item |
| 547 | for item in config["args"]] |
| 548 | arguments = config.get("args") or [] |
| 549 | root = explicit_roots.get(name) |
| 550 | if config.get("transport") == "stdio" and root is None and isinstance(arguments, list) and len(arguments) == 1: |
| 551 | arg = arguments[0] |
| 552 | if isinstance(arg, str) and not DSH_ENTRY.fullmatch(arg): |
| 553 | candidate = Path(arg) |
| 554 | if candidate.is_absolute(): |
| 555 | resolved = plain_path(candidate) |
| 556 | require(resolved.is_file(), f"`args` in `{name}` resolves to a host path that does not exist here") |
| 557 | require(DSH_ENTRY.fullmatch(resolved.name) is not None, |
| 558 | f"`args` in `{name}` resolves to a file that is not a .mjs/.js/.cjs entry") |
| 559 | implicit_roots[name] = resolved.parent |
| 560 | config["args"] = [resolved.name] |
| 561 | notes.append(f"`{name}`: `!!js`/`args` resolved to host path; copied {resolved.parent} as its source root") |
| 562 | root = resolved.parent |
| 563 | elif isinstance(arg, str) and ".." not in arg.split("/"): |
| 564 | candidate = bundle / arg |
| 565 | cwd = config.get("cwd", "") |
| 566 | if candidate.is_file(): |
| 567 | implicit_roots[name] = bundle |
| 568 | root = bundle |
| 569 | elif (isinstance(cwd, str) and cwd not in ("", ".") and ".." not in Path(cwd).parts |
| 570 | and not Path(cwd).is_absolute() and (bundle / cwd / arg).is_file()): |
| 571 | implicit_roots[name] = bundle / cwd |
| 572 | root = bundle / cwd |
| 573 | config["cwd"] = "." |
| 574 | require(free_of_js(config), f"an unevaluated `!!js` remains in `{name}`; author that field explicitly") |
| 575 | local = config.get("transport") == "stdio" |
| 576 | if local: |
| 577 | converted = stdio_server(config, "dsh", {}, name, root or implicit_roots.get(name)) |
| 578 | else: |
| 579 | converted, host = remote_server(config, "dsh", {}) |
| 580 | hosts.append(host) |
| 581 | if disabled: |
| 582 | converted["extensions"]["net.codewhale"]["disabled"] = True |
| 583 | servers[name] = converted |
| 584 | |
| 585 | def walk(rows): |
| 586 | for row in rows: |
| 587 | if not isinstance(row, dict): |
| 588 | continue |
| 589 | config = row.get("config") |
| 590 | if row.get("group") is True and isinstance(config, list): |
| 591 | walk(config) |
| 592 | continue |
| 593 | name = row.get("name") |
| 594 | if isinstance(row.get("disabled"), JsExpr): |
| 595 | notes.append(f"{label(row)} skipped: `disabled` is a `!!js` expression that cannot be evaluated offline") |
| 596 | continue |
| 597 | if name == DSH_MCP_CLIENT: |
| 598 | try: |
| 599 | mcp_row(row) |
| 600 | except ConversionError as reason: |
| 601 | notes.append(f"{label(row)} skipped: {reason}") |
| 602 | elif name == DSH_SKILL_FILESYSTEM: |
| 603 | dirs = config.get("customSkillDirs") if isinstance(config, dict) else None |
| 604 | if not isinstance(dirs, list) or not dirs: |
| 605 | notes.append(f"{label(row)} skipped: skill row has no `customSkillDirs` to import") |
| 606 | continue |
| 607 | for entry in dirs: |
| 608 | if isinstance(entry, JsExpr) or not isinstance(entry, str): |
| 609 | notes.append(f"{label(row)}: a `customSkillDirs` entry is not a literal path; skipped") |
| 610 | continue |
| 611 | candidate = Path(entry) |
| 612 | if candidate.is_absolute() or ".." in candidate.parts: |
| 613 | notes.append(f"{label(row)}: `customSkillDirs` entry `{entry}` is outside the bundle; " |
| 614 | "pass it explicitly with --skill") |
| 615 | continue |
| 616 | resolved = bundle / entry |
| 617 | if not resolved.is_dir(): |
| 618 | notes.append(f"{label(row)}: `customSkillDirs` entry `{entry}` does not exist in the bundle; skipped") |
| 619 | continue |
| 620 | skill_dirs.append(resolved) |
| 621 | else: |
| 622 | shown = name if isinstance(name, str) else "unlabeled" |
| 623 | notes.append(f"{label(row)} ({shown}) skipped: only dsh-mcp-client and dsh-skill-filesystem rows convert") |
| 624 | |
| 625 | walk(entries) |
| 626 | skills = [] |
| 627 | for directory in skill_dirs: |
| 628 | for child in sorted(directory.iterdir()): |
| 629 | if child.name.startswith("."): |
| 630 | continue |
| 631 | if (child / "SKILL.md").is_file() or child.suffix == ".md": |
| 632 | skills.append(child) |
| 633 | return servers, hosts, skills, implicit_roots, notes |
| 634 | |
| 635 | |
| 636 | def convert(args): |
| 637 | require(NAME.fullmatch(args.name) is not None and ".." not in args.name and "--" not in args.name, |
| 638 | "Choose a native plugin name: 1–64 lowercase letters/digits with single internal dots or hyphens.") |
| 639 | bundle_arg = getattr(args, "bundle", None) |
| 640 | require(bundle_arg is None or args.format == "dsh", "--bundle reads a DeepSeek Harness bundle package; use --format dsh.") |
| 641 | require(not (bundle_arg is not None and args.config), "Select --bundle or --config, not both.") |
| 642 | output = Path(os.path.abspath(args.output)) |
| 643 | plain_path(output.parent) |
| 644 | require(not os.path.lexists(output), "Output already exists; choose a fresh directory. Nothing was overwritten.") |
| 645 | files, skill_names, notes = {}, set(), [] |
| 646 | skill_sources = [plain_path(path) for path in args.skill] |
| 647 | servers, hosts, ignored = {}, [], 0 |
| 648 | roots = {} |
| 649 | for specification in getattr(args, "stdio_root", []): |
| 650 | name, separator, directory = specification.partition("=") |
| 651 | require(separator and directory and name not in roots, "Use unique --stdio-root SERVER=DIRECTORY selections.") |
| 652 | source = plain_path(directory) |
| 653 | require(source.is_dir(), "The selected stdio root must be a directory.") |
| 654 | require(source != output and source not in output.parents, "Output must be outside the selected MCP source.") |
| 655 | roots[name] = source |
| 656 | bundle_manifest = None |
| 657 | if bundle_arg is not None: |
| 658 | bundle_manifest, entries, bundle_notes = load_dsh_bundle(bundle_arg) |
| 659 | notes += bundle_notes |
| 660 | bundle = plain_path(bundle_arg) |
| 661 | require(bundle != output and bundle not in output.parents, "Output must be outside the selected bundle.") |
| 662 | servers, hosts, bundled_skills, implicit_roots, row_notes = dsh_bundle_components(entries, bundle, roots) |
| 663 | notes += row_notes |
| 664 | skill_sources += bundled_skills |
| 665 | implicit_roots.update(roots) |
| 666 | explicit_names = set(roots) |
| 667 | roots = implicit_roots |
| 668 | require(explicit_names <= set(servers), "Every --stdio-root must name a selected local MCP server.") |
| 669 | else: |
| 670 | require(not roots or args.config, "--stdio-root requires a selected MCP configuration.") |
| 671 | servers, hosts, ignored = mcp_config(args.config, args.format, roots) if args.config else ({}, [], 0) |
| 672 | for source in skill_sources: |
| 673 | require(source != output and source not in output.parents, "Output must be outside the selected skill.") |
| 674 | name, additions = skill_files(source, MAX_FILES - len(files), MAX_BYTES - sum(map(len, files.values()))) |
| 675 | require(name not in skill_names, "Duplicate skill name; no files were written.") |
| 676 | skill_names.add(name) |
| 677 | files.update(additions) |
| 678 | for name, source in roots.items(): |
| 679 | files.update(stdio_files(source, name, MAX_FILES - len(files), MAX_BYTES - sum(map(len, files.values())))) |
| 680 | require(files or servers, "No portable components selected. Use --skill, --config or --bundle.") |
| 681 | manifest = {"$schema": "https://agent-plugins.org/schemas/plugin.json", "name": args.name} |
| 682 | if bundle_manifest is not None: |
| 683 | for field in ("version", "description"): |
| 684 | value = bundle_manifest.get(field) |
| 685 | if isinstance(value, str) and value.strip(): |
| 686 | manifest[field] = value |
| 687 | notes.insert(0, f"source package: {bundle_manifest.get('name', 'unnamed')}" |
| 688 | + (f"@{bundle_manifest['version']}" if isinstance(bundle_manifest.get('version'), str) else "")) |
| 689 | extension = {} |
| 690 | if hosts: |
| 691 | extension["capabilities"] = {"network_hosts": sorted(set(hosts))} |
| 692 | if roots: |
| 693 | extension["when"] = {"binaries": ["node"]} |
| 694 | if extension: |
| 695 | manifest["extensions"] = {"net.codewhale": extension} |
| 696 | files["plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() |
| 697 | if servers: |
| 698 | files["mcp.json"] = (json.dumps({"mcpServers": servers}, indent=2) + "\n").encode() |
| 699 | notes_text = ("Bundle diagnostics:\n" + "\n".join(f"- {note}" for note in notes) + "\n\n") if notes else "" |
| 700 | files["CONVERSION.md"] = (f"# Conversion receipt\n\nSource dialect: {args.format}.\n" |
| 701 | f"Converted {len(skill_names)} selected Skills, {len(servers) - len(roots)} remote and {len(roots)} local MCP declarations.\n" |
| 702 | f"Ignored {ignored} unrelated top-level application settings.\n\n" |
| 703 | + notes_text + |
| 704 | "No source code, package manager, install hook, network request or credential lookup ran.\n" |
| 705 | "Companion skill files were copied as data; review them before loading a skill.\n" |
| 706 | "Selected Node source, dependencies and resources were copied as data into mcp/<server>.\n" |
| 707 | "Each --stdio-root is the original process working directory; the staged copy becomes its cwd.\n" |
| 708 | "Review all copied files and environment names. Node runs only through the native trusted MCP lifecycle.\n" |
| 709 | "Local MCP runs with host-user process authority, not an OS sandbox; stdio does not confine its network or files.\n" |
| 710 | "Mutable workspace state, writable package resources and external module dependencies require a manual port.\n" |
| 711 | "This output is not installed, trusted or enabled. Run `/plugin install <directory>`,\n" |
| 712 | "then `/plugin validate <name>` and review the exact trust token before enabling.\n" |
| 713 | "Remote MCP output uses Streamable HTTP only; OpenCode's legacy SSE fallback is not reproduced.\n" |
| 714 | "Conversion does not prove server connectivity or foreign runtime compatibility.\n").encode() |
| 715 | require(len(files) <= MAX_FILES and sum(map(len, files.values())) <= MAX_BYTES, |
| 716 | "Output exceeds the 4096-file / 64 MiB bundle budget.") |
| 717 | # Complete all parsing before exclusive creation. Never install or alter a source tree. |
| 718 | output.mkdir(mode=0o700) |
| 719 | written = [] |
| 720 | try: |
| 721 | for relative, content in files.items(): |
| 722 | destination = output / relative |
| 723 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 724 | with destination.open("xb") as handle: |
| 725 | written.append(destination) |
| 726 | handle.write(content) |
| 727 | except OSError: |
| 728 | # Remove only files this operation created, never a pre-existing tree. |
| 729 | for destination in reversed(written): |
| 730 | destination.unlink(missing_ok=True) |
| 731 | for current, dirs, _ in os.walk(output, topdown=False): |
| 732 | for directory in dirs: |
| 733 | (Path(current) / directory).rmdir() |
| 734 | output.rmdir() |
| 735 | raise |
| 736 | return len(skill_names), len(servers), ignored |
| 737 | |
| 738 | |
| 739 | def main(): |
| 740 | parser = argparse.ArgumentParser(description=__doc__) |
| 741 | parser.add_argument("--format", choices=("opencode-v1", "opencode-v2", "dsh"), required=True) |
| 742 | parser.add_argument("--config", type=Path, help="OpenCode JSON or static DSH Cordis YAML/JSON (optional)") |
| 743 | parser.add_argument("--bundle", type=Path, |
| 744 | help="dsh bundle package directory (package.json with dsh.bundle.patch); evaluates the patch layer") |
| 745 | parser.add_argument("--skill", type=Path, action="append", default=[], help="Explicit skill directory or Markdown file; repeatable") |
| 746 | parser.add_argument("--stdio-root", action="append", default=[], metavar="SERVER=DIRECTORY", |
| 747 | help="Explicit packaged Node MCP working directory; repeat for each selected local server") |
| 748 | parser.add_argument("--name", required=True, help="Name for the new native bundle") |
| 749 | parser.add_argument("--output", type=Path, required=True, help="Fresh directory whose parent already exists") |
| 750 | args = parser.parse_args() |
| 751 | try: |
| 752 | skills, servers, ignored = convert(args) |
| 753 | except (ConversionError, OSError, UnicodeError) as error: |
| 754 | message = str(error) if isinstance(error, ConversionError) else "File operation failed; source and output must be accessible regular paths." |
| 755 | print(f"Conversion refused: {message}", file=sys.stderr) |
| 756 | return 1 |
| 757 | print(f"Prepared {skills} Skills and {servers} MCP declarations; {ignored} unrelated settings omitted.") |
| 758 | print("Review CONVERSION.md, then use the existing /plugin install, validate, trust and enable commands.") |
| 759 | return 0 |
| 760 | |
| 761 | |
| 762 | if __name__ == "__main__": |
| 763 | sys.exit(main()) |
| 764 |