#!/usr/bin/env python3 """NianCode's fixed, keyless adapter for the Meowa game-assets Skill. The Meowa credential stays in Electron Main. This CLI talks only to the local NianCode Host API and writes generated output plus provenance metadata into the active project. """ from __future__ import annotations import argparse import base64 from datetime import datetime, timezone import json import mimetypes import os from pathlib import Path import re import sys import time from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen SKILL_VERSION = "2026.06.19.1" DEFAULT_HOST_BASE = "http://127.0.0.1:13210" DEFAULT_OUTPUT_DIR = "assets/generated/meowa" TERMINAL_STATUSES = {"success", "completed", "failure", "failed", "cancelled", "canceled"} class AdapterError(RuntimeError): pass def host_base() -> str: value = os.environ.get("NIANCODE_HOST_API_BASE_URL", DEFAULT_HOST_BASE).strip() return value.rstrip("/") def host_token() -> str: token = os.environ.get("NIANCODE_HOST_API_TOKEN", "").strip() if not token: raise AdapterError("NianCode Host API token is unavailable; run this command from a NianCode Agent") return token def parse_payload(raw: bytes) -> Any: if not raw: return None try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): return raw.decode("utf-8", errors="replace")[:500] def payload_message(payload: Any, fallback: str) -> str: if isinstance(payload, dict): for key in ("error", "message", "detail", "msg"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value.strip()[:500] if isinstance(payload, str) and payload.strip(): return payload.strip()[:500] return fallback def request_json(path: str, method: str = "GET", payload: dict[str, Any] | None = None, timeout: int = 240) -> Any: url = path if path.startswith("http://") or path.startswith("https://") else f"{host_base()}{path}" body = None headers = { "Accept": "application/json", "Authorization": f"Bearer {host_token()}", } if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" request = Request(url, data=body, headers=headers, method=method) try: with urlopen(request, timeout=timeout) as response: return parse_payload(response.read()) except HTTPError as error: payload_value = parse_payload(error.read()) raise AdapterError(payload_message(payload_value, f"Host API returned HTTP {error.code}")) from error except URLError as error: raise AdapterError(f"无法连接 NianCode Host API:{error.reason}") from error def json_dump(path: Path, value: Any) -> None: path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def print_json(value: Any) -> None: print(json.dumps(value, ensure_ascii=False, indent=2)) def cli_path() -> Path: return Path(__file__).resolve() def command_skill_doc(args: argparse.Namespace) -> int: query = urlencode({key: value for key, value in {"task": args.task, "topic": args.topic}.items() if value}) path = "/api/meowa/game-assets/skill-doc" if query: path = f"{path}?{query}" try: payload = request_json(path) content = payload.get("content") if isinstance(payload, dict) else None if isinstance(content, str) and content.strip(): print(content) return 0 raise AdapterError("Skill 文档响应没有 content") except AdapterError as error: fallback = cli_path().with_name("meowart_api.md") if fallback.is_file(): print(f"[WARN] online Meowa guide unavailable; using bundled guide: {error}", file=sys.stderr) print(fallback.read_text(encoding="utf-8")) return 0 raise def command_skill_doc_status(_args: argparse.Namespace) -> int: guide = cli_path().with_name("meowart_api.md") print_json({ "skillVersion": SKILL_VERSION, "bundledGuide": str(guide), "bundledGuideExists": guide.is_file(), "remoteGuide": "通过 NianCode Main 代理读取", }) return 0 def command_config_status(_args: argparse.Namespace) -> int: print_json(request_json("/api/meowa/game-assets/config")) return 0 def command_template_info(args: argparse.Namespace) -> int: print_json(request_json(f"/api/meowa/game-assets/template-info?kind={args.kind}")) return 0 def command_credits(_args: argparse.Namespace) -> int: print_json(request_json("/api/meowa/game-assets/credits")) return 0 def slug(value: str) -> str: value = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-_") return value[:72] or "asset" def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") def read_reference_files(paths: list[str]) -> list[dict[str, str]]: references: list[dict[str, str]] = [] total = 0 for raw_path in paths: path = Path(raw_path).expanduser() if not path.is_file(): raise AdapterError(f"参考文件不存在:{path}") data = path.read_bytes() if len(data) > 8 * 1024 * 1024: raise AdapterError(f"参考文件过大:{path}") total += len(data) if total > 16 * 1024 * 1024: raise AdapterError("参考文件总大小超过限制") references.append({ "name": path.name, "mimeType": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "dataBase64": base64.b64encode(data).decode("ascii"), }) return references def safe_request_payload(payload: dict[str, Any]) -> dict[str, Any]: result = dict(payload) references = result.get("referenceFiles") if isinstance(references, list): result["referenceFiles"] = [ {"name": item.get("name"), "mimeType": item.get("mimeType")} for item in references if isinstance(item, dict) ] return result def extension_for(mime_type: str, file_name: str) -> str: suffix = Path(file_name).suffix.lower() if suffix and len(suffix) <= 10: return suffix return { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "application/zip": ".zip", "audio/mpeg": ".mp3", "audio/wav": ".wav", }.get(mime_type.lower(), ".bin") def download_job(kind: str, job_id: str, output_index: int | None = None) -> dict[str, Any]: params: dict[str, str] = {"kind": kind, "id": job_id} if output_index is not None: params["outputIndex"] = str(output_index) payload = request_json(f"/api/meowa/game-assets/download?{urlencode(params)}") if not isinstance(payload, dict) or not payload.get("success"): raise AdapterError(payload_message(payload, "Meowa 下载响应无效")) return payload def generation_payload(args: argparse.Namespace, kind: str) -> dict[str, Any]: template_config: Any = {} if args.template_config: try: template_config = json.loads(args.template_config) except json.JSONDecodeError as error: raise AdapterError(f"template-config 不是有效 JSON:{error}") from error if not isinstance(template_config, dict): raise AdapterError("template-config 必须是 JSON 对象") payload: dict[str, Any] = { "kind": kind, "templateName": args.template_name, "templateConfig": template_config, "requirement": args.requirement, "jobName": args.job_name, "modelName": args.model_name, "resolution": args.resolution, "aspectRatio": args.aspect_ratio, "temperature": args.temperature, "includeBase64": args.include_base64, "referenceFiles": read_reference_files(args.reference_file), } if kind == "hd": payload.update({ "hdRemoveBgMode": args.remove_bg_mode, "projectId": args.project_id, "threadId": args.thread_id, }) return payload def poll_job(kind: str, job_id: str, max_wait: int, poll_interval: float) -> dict[str, Any]: deadline = time.time() + max(max_wait, 1) last: dict[str, Any] = {} while time.time() <= deadline: payload = request_json(f"/api/meowa/game-assets/jobs?{urlencode({'kind': kind, 'id': job_id})}") if not isinstance(payload, dict): raise AdapterError("Meowa 任务响应不是 JSON 对象") last = payload status = str(payload.get("status") or "").strip().lower() print(f"[INFO] meowa job={job_id} status={status or 'unknown'}", file=sys.stderr) if status in TERMINAL_STATUSES: return payload time.sleep(max(poll_interval, 0.2)) raise AdapterError(f"Meowa 任务等待超时:{job_id},最后状态 {last.get('status', 'unknown')}") def command_generate(args: argparse.Namespace, kind: str) -> int: payload = generation_payload(args, kind) output_root = Path(args.output_dir).expanduser() run_dir = output_root / f"{slug(args.template_name)}-{int(time.time())}" run_dir.mkdir(parents=True, exist_ok=True) started_at = now_iso() json_dump(run_dir / "request.meta.json", { "provider": "meowa", "skillVersion": SKILL_VERSION, "kind": kind, "startedAt": started_at, "request": safe_request_payload(payload), }) try: submit = request_json("/api/meowa/game-assets/generate", method="POST", payload=payload) json_dump(run_dir / "submit_response.json", submit) if not isinstance(submit, dict): raise AdapterError("Meowa 提交响应不是 JSON 对象") job_id = str(submit.get("api_job_id") or submit.get("job_id") or "").strip() if not job_id: raise AdapterError("Meowa 提交响应缺少任务 ID") print(f"[INFO] submitted meowa job={job_id}", file=sys.stderr) final = poll_job(kind, job_id, args.max_wait, args.poll_interval) json_dump(run_dir / "final_response.json", final) status = str(final.get("status") or "").strip().lower() if status not in {"success", "completed"}: raise AdapterError(f"Meowa 任务未成功结束:{status or 'unknown'}") download = download_job(kind, job_id) file_name = str(download.get("fileName") or f"{job_id}.bin") mime_type = str(download.get("mimeType") or "application/octet-stream") encoded = download.get("dataBase64") if not isinstance(encoded, str) or not encoded: raise AdapterError("Meowa 下载响应缺少文件内容") file_path = run_dir / f"asset{extension_for(mime_type, file_name)}" file_path.write_bytes(base64.b64decode(encoded)) metadata = { "provider": "meowa", "integration": "niancode-meowa-game-assets", "skillVersion": SKILL_VERSION, "kind": kind, "templateName": args.template_name, "requirement": args.requirement, "jobId": job_id, "generatedAt": now_iso(), "localPath": str(file_path.as_posix()), "mimeType": mime_type, "sourceType": "ai-generated", "licenseStatus": "verify-meowa-plan-before-shipping", "commercialUse": "依 Meowa 当前账户方案和条款确认;不得默认视为 CC0。", "termsUrl": "https://meowa.ai/tos", "upstreamSkill": "https://github.com/Meowa-AI/meowa-skills/tree/main/skills/game-assets", } json_dump(run_dir / "generation.meta.json", metadata) print(f"[INFO] saved_dir={run_dir}", file=sys.stderr) print_json({"success": True, "jobId": job_id, "file": str(file_path.as_posix()), "metadata": str((run_dir / 'generation.meta.json').as_posix())}) return 0 except Exception as error: json_dump(run_dir / "error.json", {"error": str(error), "failedAt": now_iso()}) raise def add_generation_args(parser: argparse.ArgumentParser, kind: str) -> None: parser.add_argument("--template-name", required=True) parser.add_argument("--requirement", required=True) parser.add_argument("--template-config", default="") parser.add_argument("--job-name", default="") parser.add_argument("--model-name", default="gemini-3.1-flash-image-preview" if kind == "hd" else "") parser.add_argument("--resolution", default="") parser.add_argument("--aspect-ratio", default="1:1") parser.add_argument("--temperature", type=float, default=0.0) parser.add_argument("--include-base64", action="store_true") parser.add_argument("--reference-file", action="append", default=[]) parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR) parser.add_argument("--max-wait", type=int, default=900) parser.add_argument("--poll-interval", type=float, default=3.0) if kind == "hd": parser.add_argument("--remove-bg-mode", default="") parser.add_argument("--project-id", default=None) parser.add_argument("--thread-id", default=None) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="NianCode keyless adapter for Meowa game assets") subparsers = parser.add_subparsers(dest="command", required=True) skill_doc = subparsers.add_parser("skill-doc") skill_doc.add_argument("--task", default="") skill_doc.add_argument("--topic", default="") skill_doc.set_defaults(handler=command_skill_doc) status = subparsers.add_parser("skill-doc-status") status.set_defaults(handler=command_skill_doc_status) config = subparsers.add_parser("config-status") config.set_defaults(handler=command_config_status) template = subparsers.add_parser("template-info") template.add_argument("--kind", choices=("pixel", "hd"), required=True) template.set_defaults(handler=command_template_info) credits = subparsers.add_parser("credits") credits.set_defaults(handler=command_credits) for kind in ("pixel", "hd"): generator = subparsers.add_parser(f"{kind}-gen") add_generation_args(generator, kind) generator.set_defaults(handler=lambda args, selected_kind=kind: command_generate(args, selected_kind)) return parser def main() -> int: args = build_parser().parse_args() try: return int(args.handler(args)) except AdapterError as error: print(f"[ERROR] {error}", file=sys.stderr) return 1 except (OSError, ValueError, json.JSONDecodeError) as error: print(f"[ERROR] {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())