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