返回 CodeWhale
convert-plugin.py
根目录 / scripts / convert-plugin.py
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
764 lines PYTHON