@miravia_test/miravia-publish-skill 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/SKILL.md CHANGED
@@ -112,10 +112,12 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
112
112
  | `need_brand_create` | 品牌未找到,需去 Seller Center 处理 | 先引导查看已有品牌及审核状态(https://sellercenter.miravia.es/apps/setting/seller/brandlist,可能已在审核中),确无再创建(https://sellercenter.miravia.es/apps/product/publish/brand);创建后重新运行命令 |
113
113
  | `need_mandatory_fields` | 缺少必填字段;**图片缺失为硬阻断(不可 Draft)**,价格/重量/尺寸缺失可 `--draft` | 补充字段后重跑 |
114
114
  | `need_category_attributes` | 类目必填属性缺失(AI抽取模式) | 通过 `--attribute-data` 补充或使用 `--draft` |
115
+ | `need_attribute_mapping` | Excel/CSV 列未能自动匹配到类目属性(不静默丢弃),返回该列及其候选属性 | 向用户展示候选属性,确认后用 `--attribute-data '{"<属性名>":"<值>"}'` 重跑;非商品属性则删除该列 |
115
116
  | `need_field_summary` | 需要确认字段摘要或选择草稿模式 | 回传 `field_summary_token`,可加 `--draft` |
116
117
  | `need_compliance` | 制造商/EU 负责人匹配失败 | 按返回候选选择或补充信息后重跑 |
117
118
  | `compliance_missing_suggest_draft` | 合规字段缺失(制造商/欧代/Safety Warning内容/合规文档) | 补充合规字段或加 `--draft` 发布为草稿 |
118
119
  | `gpsr_missing_suggest_draft` | GPSR 字段缺失(制造商和/或欧盟负责人为空) | 补充 GPSR 字段或加 `--draft` 发布为草稿 |
120
+ | `need_safety_warning_confirm` | AI/Excel 源检测到安全相关内容,但卖家未显式设置「该产品是否有安全警告」;系统**不会**静默默认为 No | 展示 `detected_safety_content` 证据与当前值,让卖家确认:确有则加 `--safety-warning yes`(必要时 `--safety-warning-content`)重跑;确无则加 `--safety-warning no` 确认;或在 Excel 补齐该列后重跑 |
119
121
  | `post_to_inactive_missing_suggest_draft` | 类目必填字段缺失(EAN) | 补充字段或加 `--draft` 发布为草稿 |
120
122
  | `special_category_block` | 特殊类目规则阻断(PPU 值无效 / 图书类目 ISBN 缺失) | 修正错误后重跑,不可转 Draft |
121
123
  | `special_category_suggest_draft` | 特殊类目字段缺失(PPU 缺失 / 食品属性缺失) | 补充字段或加 `--draft` 发布为草稿 |
package/dist/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.7
1
+ 0.1.9
@@ -220,6 +220,31 @@ def require_business_credentials() -> None:
220
220
  pass
221
221
 
222
222
 
223
+ def has_valid_business_credentials() -> bool:
224
+ """Return True when credentials are present and not expired.
225
+
226
+ Non-raising companion to ``require_business_credentials``. The publish
227
+ flow uses this to decide whether the seller is already authorized: when
228
+ valid credentials exist (they can only exist after a successful
229
+ ``auth_cli.py login``, which grants the read-profile / access-products /
230
+ update-products scopes), the skill must not re-prompt the three-item
231
+ authorization consent on every run. Returns False for missing or expired
232
+ credentials so the caller falls back to asking the seller.
233
+ """
234
+ if not (APP_KEY and APP_SECRET and ACCESS_TOKEN):
235
+ return False
236
+
237
+ if TOKEN_EXPIRES_AT:
238
+ try:
239
+ expires_at_ms = int(TOKEN_EXPIRES_AT)
240
+ if expires_at_ms > 0 and int(time.time() * 1000) > expires_at_ms - 60_000:
241
+ return False
242
+ except ValueError:
243
+ pass
244
+
245
+ return True
246
+
247
+
223
248
  def mask_token(token: str) -> str:
224
249
  if not token:
225
250
  return "<empty>"
@@ -371,6 +371,7 @@ _TABLE_FIELD_SYNONYMS: Dict[str, tuple] = {
371
371
  "length_cm": ("长度", "长cm", "长", "length", "largura"),
372
372
  "width_cm": ("宽度", "宽cm", "宽", "width", "ancho"),
373
373
  "height_cm": ("高度", "高cm", "高", "height", "altura"),
374
+ "delivery_method": ("deliverymethod", "delivery", "shippingmethod", "shipping", "物流方式", "物流", "发货方式", "配送方式", "metododeenvío", "metododeenvio", "envío", "envio", "spedizione", "metododiconsegna"),
374
375
  "manufacturer_name": ("制造商", "厂家", "manufacturer", "fabricante"),
375
376
  "eu_responsible_person_name": ("欧盟负责人", "欧代", "euresponsibleperson", "eurp"),
376
377
  "variation_1": ("颜色", "color", "colour", "variation1", "variation", "款式"),
@@ -389,6 +390,7 @@ _PRODUCT_LEVEL_FIELDS = {
389
390
  "length_cm",
390
391
  "width_cm",
391
392
  "height_cm",
393
+ "delivery_method",
392
394
  "manufacturer_name",
393
395
  "eu_responsible_person_name",
394
396
  }
@@ -218,6 +218,11 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
218
218
  "pub_compliance_manufacturer_eurp_both_empty": "Manufacturer and EU Responsible Person are both empty. Non-EU manufacturers require an EU Responsible Person.",
219
219
  "pub_compliance_eurp_missing_with_manufacturer": "Manufacturer is filled but EU Responsible Person is empty. Non-EU manufacturers require an EU Responsible Person.",
220
220
  "pub_safety_warning_content_missing": "Safety Warning is set to Yes but the warning content is empty. Please provide the safety warning content.",
221
+ "pub_safety_confirm_title": "Safety warning needs your confirmation",
222
+ "pub_safety_confirm_message": "We detected safety-related content in your source ({evidence}), but the 'Does this product have a safety warning' flag was not set explicitly. We will NOT silently default it to 'No'. Please confirm the correct value, or complete the field in your Excel and rerun.",
223
+ "pub_safety_confirm_next_yes": "If the product does have a safety warning, rerun with --safety-warning yes (add --safety-warning-content if needed).",
224
+ "pub_safety_confirm_next_no": "If it genuinely has no safety warning, rerun with --safety-warning no to confirm.",
225
+ "pub_safety_confirm_next_excel": "Your Excel fields may be incomplete; you can fill the safety column there and rerun.",
221
226
  "pub_compliance_docs_missing": "Compliance documents are required for this category but not provided.",
222
227
  "pub_compliance_missing_message": "Some compliance fields are missing. You can fill them or publish to Draft.",
223
228
  "pub_compliance_fill_or_draft": "Fill the missing compliance fields, or publish as Draft to complete them later.",
@@ -249,6 +254,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
249
254
  "pub_dbm_dim_sum_exceeded": "Dimension sum {dim_sum} exceeds DBM limit {limit}cm.",
250
255
  "pub_dbm_volume_exceeded": "Volumetric weight {volume} exceeds DBM limit {limit}.",
251
256
  "pub_dbm_auto_switch_to_dbs": "Package exceeds DBM limits. Delivery method auto-switched to DBS.",
257
+ "pub_dbs_no_permission_downgrade": "Seller requested DBS but this account has no DBS permission; the delivery method was set to DBM.",
252
258
  "pub_dbm_no_dbs_permission": "Package exceeds DBM limits but seller has no DBS permission. Cannot auto-switch to DBS.",
253
259
  "pub_dbm_fix_or_apply_dbs": "Please reduce package size/weight to meet DBM limits, or apply for DBS permission in Seller Center.",
254
260
  # SKU already exists (concurrency conflict)
@@ -338,6 +344,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
338
344
  "pub_need_category_attributes_description": "The category {category_id} requires the following attributes that were not found in the extracted data. Please provide them via --attribute-data or rerun with --draft to publish as an inactive draft.",
339
345
  "pub_need_category_attributes_item": "{idx}. {label} ({en_name}) - {input_type}",
340
346
  "pub_need_category_attributes_next": "Provide the missing attributes and rerun with --attribute-data '<json>', or use --draft to publish as inactive draft and complete them later.",
347
+ "pub_need_attribute_mapping_title": "Some columns could not be matched automatically",
348
+ "pub_need_attribute_mapping_description": "These Excel/CSV columns did not clearly match a category attribute, so they were not bound. Please confirm which attribute each column should map to, then rerun with --attribute-data using the canonical attribute names shown below (or drop the column if it is not a product attribute).",
349
+ "pub_need_attribute_mapping_next": "Rerun with --attribute-data '{\"<attribute_name>\": \"<value>\"}' using one of the suggested attribute names, or remove the column if it is not needed.",
341
350
  "pub_html_cleaned_notice": "The product description has been cleaned: unsafe HTML tags (script, iframe, style attributes, etc.) were removed. Only safe formatting tags (p, br, b, ul, li, etc.) are kept.",
342
351
  "pub_create_pending_review": "Product submitted successfully. It is currently under Miravia review and temporarily unavailable for sale; approval may take up to 48 hours, especially if package photos or documents are required. Once approved, it will be activated automatically. If rejected, please check the Suspended tab.",
343
352
  "pub_pdp_link": "Product detail page: {url}",
@@ -883,6 +892,11 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
883
892
  "pub_compliance_manufacturer_eurp_both_empty": "制造商和欧盟负责人均为空。非欧盟制造商必须填写欧盟负责人。",
884
893
  "pub_compliance_eurp_missing_with_manufacturer": "已填写制造商但欧盟负责人为空。非欧盟制造商必须填写欧盟负责人。",
885
894
  "pub_safety_warning_content_missing": "安全警告已设为 Yes 但未填写警告内容。请补充安全警告内容。",
895
+ "pub_safety_confirm_title": "安全警告需要你确认",
896
+ "pub_safety_confirm_message": "我们从你的源文件中检测到安全相关内容({evidence}),但“该产品是否有安全警告”这一项没有被显式设置。我们不会把它静默默认为 No。请确认应取的值,或先在 Excel 中补全该项后重新运行。",
897
+ "pub_safety_confirm_next_yes": "如果该商品确实有安全警告,请加 --safety-warning yes 重新运行(必要时再加 --safety-warning-content)。",
898
+ "pub_safety_confirm_next_no": "如果确实没有安全警告,请加 --safety-warning no 确认。",
899
+ "pub_safety_confirm_next_excel": "Excel 字段可能不完整,你可以在其中补上安全警告列后重新运行。",
886
900
  "pub_compliance_docs_missing": "该类目要求上传合规文档但未提供。",
887
901
  "pub_compliance_missing_message": "部分合规字段缺失。您可以补充完整或发布为草稿。",
888
902
  "pub_compliance_fill_or_draft": "补充缺失的合规字段,或发布为草稿(Draft)稍后在 Seller Center 中完善。",
@@ -914,6 +928,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
914
928
  "pub_dbm_dim_sum_exceeded": "三边之和 {dim_sum} 超过 DBM 限制 {limit}cm。",
915
929
  "pub_dbm_volume_exceeded": "体积重 {volume} 超过 DBM 限制 {limit}。",
916
930
  "pub_dbm_auto_switch_to_dbs": "包裹超出 DBM 限制,物流方式自动转为 DBS。",
931
+ "pub_dbs_no_permission_downgrade": "商家请求使用 DBS,但当前账号没有 DBS 权限,物流方式已设为 DBM。",
917
932
  "pub_dbm_no_dbs_permission": "包裹超出 DBM 限制,但卖家无 DBS 权限,无法自动转为 DBS。",
918
933
  "pub_dbm_fix_or_apply_dbs": "请减小包裹尺寸/重量以满足 DBM 限制,或在 Seller Center 申请 DBS 权限。",
919
934
  # SKU already exists (concurrency conflict)
@@ -968,6 +983,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
968
983
  "pub_need_category_attributes_description": "类目 {category_id} 要求以下属性,但 AI 抽取的数据中未包含。请通过 --attribute-data 补充,或使用 --draft 发布为未激活草稿。",
969
984
  "pub_need_category_attributes_item": "{idx}. {label}({en_name})- {input_type}",
970
985
  "pub_need_category_attributes_next": "补充缺失属性后使用 --attribute-data '<json>' 重新运行,或使用 --draft 发布为未激活草稿稍后补充。",
986
+ "pub_need_attribute_mapping_title": "部分列未能自动匹配",
987
+ "pub_need_attribute_mapping_description": "以下 Excel/CSV 列未能明确匹配到类目属性,因此未绑定。请确认每列应对应哪个属性,然后用下方展示的规范属性名通过 --attribute-data 重新运行(若该列不是商品属性则可删除)。",
988
+ "pub_need_attribute_mapping_next": "使用下方建议的属性名之一,通过 --attribute-data '{\"<属性名>\": \"<值>\"}' 重新运行;若不需要该列则删除。",
971
989
  "pub_html_cleaned_notice": "商品描述已清理:不安全的 HTML 标签(script、iframe、style 属性等)已被移除。仅保留安全格式标签(p、br、b、ul、li 等)。",
972
990
  "pub_create_pending_review": "商品提交成功,目前正在接受 Miravia 审核,暂不可售;审核最长可能需要 48 小时,特别是需要包装照片或文件时。审核通过后会自动激活,如被拒绝请在“已暂停(Suspended)”选项卡中查看。",
973
991
  "pub_pdp_link": "商品详情页:{url}",
@@ -360,13 +360,14 @@ class ProductClient:
360
360
 
361
361
  # GPSR compliance fields are injected at SKU level (see below).
362
362
 
363
- # Delivery method -> Miravia create field ``Attributes.deliverySof``
364
- # ("DBM" or "DBS"). Only written when the seller explicitly chose one;
363
+ # Delivery method -> Miravia create field ``Attributes.delivery_option_sof``,
364
+ # which is a YES/NO flag: "Yes" = DBS (seller-fulfilled), "No" = DBM
365
+ # (platform-fulfilled). Only written when the seller explicitly chose one;
365
366
  # when absent, publish_module._check_dbm_limits applies the default
366
- # (DBM generally, DBS if the seller has DBS permission).
367
+ # (No/DBM generally, Yes/DBS if the seller has DBS permission).
367
368
  _dm = str(data.get("delivery_method") or "").strip().upper()
368
369
  if _dm in ("DBM", "DBS"):
369
- attributes["deliverySof"] = _dm
370
+ attributes["delivery_option_sof"] = "Yes" if _dm == "DBS" else "No"
370
371
 
371
372
  product_payload: Dict[str, Any] = {
372
373
  "PrimaryCategory": category_id,
@@ -405,13 +406,14 @@ class ProductClient:
405
406
  if draft:
406
407
  product_payload["Status"] = "inactive"
407
408
 
408
- # Country-specific prices (best-effort mapping)
409
- country_prices = data.get("country_general_prices", {})
410
- if country_prices:
411
- product_payload["CountryPrice"] = [
412
- {"country": c, "price": str(p)}
413
- for c, p in country_prices.items()
414
- ]
409
+ # Country-specific prices are NOT injected here at the product level.
410
+ # Verified against the authoritative /product/create contract: each
411
+ # country override lives INSIDE the SKU as
412
+ # ``CountryPrice: [{"CountryCode":"ES","Price":"1.9","SalePrice":"1.8"}]``
413
+ # (capitalised keys). A product-level ``CountryPrice`` list with the
414
+ # old ``{"country","price"}`` shape is silently ignored by the API.
415
+ # This is built per SKU in
416
+ # ``PublishModule._build_sku_list_from_schema``.
415
417
 
416
418
  # Standard unit type / count
417
419
  if data.get("standard_unit_type"):
@@ -373,6 +373,25 @@ def _print_conversational_result(result: Dict[str, Any]) -> None:
373
373
  print(f"• {action}")
374
374
  return
375
375
 
376
+ # Unmapped Excel/CSV columns needing a confirmed attribute mapping
377
+ if status == "need_attribute_mapping":
378
+ print(result.get("title", ""))
379
+ print(result.get("message", ""))
380
+ print()
381
+ for item in result.get("unmapped_columns", []):
382
+ print(f" 列 \"{item.get('header')}\" = {item.get('value')} 可能对应:")
383
+ for c in item.get("candidates", []):
384
+ mand = "(必填)" if str(c.get("is_mandatory")) == "1" else ""
385
+ print(
386
+ f" - {c.get('label')} "
387
+ f"[name={c.get('name')}, en_name={c.get('en_name')}, "
388
+ f"type={c.get('attribute_type')}]{mand}"
389
+ )
390
+ print()
391
+ for action in result.get("next_actions", []):
392
+ print(f"• {action}")
393
+ return
394
+
376
395
  if status == "need_images":
377
396
  print(result.get("message", ""))
378
397
  if result.get("failed_images"):
@@ -796,7 +815,8 @@ def main() -> None:
796
815
  print(json.dumps(auth_payload, indent=2, ensure_ascii=False))
797
816
  return
798
817
 
799
- if not skip_credential_check and args.scenario and not getattr(args, "authorized", False):
818
+ already_authorized = getattr(args, "authorized", False) or config.has_valid_business_credentials()
819
+ if not skip_credential_check and args.scenario and not already_authorized:
800
820
  auth_payload = {
801
821
  "status": "need_authorization",
802
822
  "title": i18n._("pub_authorization_title"),
@@ -801,24 +801,55 @@ class PublishModule:
801
801
  *,
802
802
  has_dbs_permission: bool = False,
803
803
  ) -> Optional[Dict[str, Any]]:
804
- """Resolve ``Attributes.deliverySof`` and enforce DBM package limits.
805
-
806
- Miravia ``/product/create`` expects ``Attributes.deliverySof`` to be
807
- ``"DBM"`` or ``"DBS"``. When the seller did not choose one it is
808
- defaulted here: generally DBM, but DBS when the seller has DBS
809
- permission. If the effective method is DBM but the package exceeds the
810
- DBM limits (weight <= 40KG, L+W+H <= 300cm, volumetric weight
811
- L*W*H/6000 <= 40), it is switched to DBS when permitted, otherwise the
812
- caller blocks. Returns a result dict, or None when nothing to do.
804
+ """Resolve ``Attributes.delivery_option_sof`` and enforce DBM package limits.
805
+
806
+ Miravia ``/product/create`` expects ``Attributes.delivery_option_sof`` to be
807
+ a YES/NO flag: ``"Yes"`` = DBS (seller-fulfilled), ``"No"`` = DBM
808
+ (platform-fulfilled). The legacy ``deliverySof="DBM"/"DBS"`` literal is
809
+ silently ignored by the platform. Internally we normalise to ``"DBM"``/
810
+ ``"DBS"`` for the package-limit logic and always write the flag back as
811
+ Yes/No. When the seller did not choose one it is defaulted here: generally
812
+ DBM (No), but DBS (Yes) when the seller has DBS permission. If the
813
+ effective method is DBM but the package exceeds the DBM limits (weight
814
+ <= 40KG, L+W+H <= 300cm, volumetric weight L*W*H/6000 <= 40), it is
815
+ switched to DBS when permitted, otherwise the caller blocks. Returns a
816
+ result dict, or None when nothing to do.
813
817
  """
814
818
  attrs = product_payload.setdefault("Attributes", {})
815
- delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
816
819
 
817
- # Default when there is no explicit entry: DBS if the seller is
818
- # permitted, otherwise DBM.
820
+ def _write_flag(method: str) -> None:
821
+ # Canonical wire flag the platform actually honours; replaces the
822
+ # legacy deliverySof value so no stale key lingers.
823
+ attrs["delivery_option_sof"] = "Yes" if method == "DBS" else "No"
824
+ attrs.pop("deliverySof", None)
825
+
826
+ _raw = str(
827
+ attrs.get("delivery_option_sof") or attrs.get("deliverySof") or ""
828
+ ).strip().upper()
829
+ # Accept the wire flag (Yes/No) as well as legacy DBM/DBS codes.
830
+ if _raw in ("YES", "DBS"):
831
+ delivery_method = "DBS"
832
+ elif _raw in ("NO", "DBM"):
833
+ delivery_method = "DBM"
834
+ else:
835
+ delivery_method = ""
836
+ downgraded_from_dbs = False
837
+
819
838
  if delivery_method not in ("DBM", "DBS"):
839
+ # No explicit entry: default to DBS when the seller is permitted,
840
+ # otherwise DBM.
820
841
  delivery_method = "DBS" if has_dbs_permission else "DBM"
821
- attrs["deliverySof"] = delivery_method
842
+ _write_flag(delivery_method)
843
+ elif delivery_method == "DBS" and not has_dbs_permission:
844
+ # Seller explicitly chose DBS but the account has no DBS permission:
845
+ # never grant DBS without permission — fall back to DBM and still
846
+ # enforce the DBM package limits below.
847
+ delivery_method = "DBM"
848
+ _write_flag(delivery_method)
849
+ downgraded_from_dbs = True
850
+ else:
851
+ # Normalise an explicit/legacy value to the canonical Yes/No flag.
852
+ _write_flag(delivery_method)
822
853
 
823
854
  # Only DBM carries package limits.
824
855
  if delivery_method != "DBM":
@@ -856,12 +887,17 @@ class PublishModule:
856
887
  )
857
888
 
858
889
  if not exceeded:
890
+ if downgraded_from_dbs:
891
+ return {
892
+ "downgraded_to_dbm": True,
893
+ "message": i18n._("pub_dbs_no_permission_downgrade"),
894
+ }
859
895
  return None
860
896
 
861
897
  # Exceeded DBM limits — check DBS permission
862
898
  if has_dbs_permission:
863
899
  # Auto-switch to DBS
864
- attrs["deliverySof"] = "DBS"
900
+ _write_flag("DBS")
865
901
  return {
866
902
  "switched_to_dbs": True,
867
903
  "reasons": exceeded,
@@ -902,11 +938,14 @@ class PublishModule:
902
938
  seller_sku_required = self._field_is_required(
903
939
  "SellerSku", category_attributes or []
904
940
  )
905
- _validate_create_payload(product_payload, seller_sku_required=seller_sku_required)
941
+ _validate_create_payload(
942
+ product_payload, seller_sku_required=seller_sku_required, draft=draft
943
+ )
906
944
 
907
945
  # Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
908
946
  has_dbs = self.product.get_shipping_permission()
909
947
  dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
948
+ dbm_notes: List[str] = []
910
949
  if dbm_result:
911
950
  if dbm_result.get("blocked"):
912
951
  # No DBS permission and exceeds DBM limits — block
@@ -919,10 +958,12 @@ class PublishModule:
919
958
  ],
920
959
  }
921
960
  if dbm_result.get("switched_to_dbs"):
922
- if draft_issues is not None:
923
- draft_issues.append(dbm_result["message"])
924
- for reason in dbm_result.get("reasons", []):
925
- draft_issues.append(f" - {reason}")
961
+ dbm_notes.append(dbm_result["message"])
962
+ dbm_notes.extend(f" - {r}" for r in dbm_result.get("reasons", []))
963
+ if dbm_result.get("downgraded_to_dbm"):
964
+ # Seller asked for DBS but has no DBS permission — explain the
965
+ # fallback so the delivery-method change is not silent.
966
+ dbm_notes.append(dbm_result["message"])
926
967
 
927
968
  # Run pre-validate early so errors are visible in the preview
928
969
  prevalidate_result = self._run_prevalidate(product_payload)
@@ -935,6 +976,7 @@ class PublishModule:
935
976
 
936
977
  # Merge all issues for preview
937
978
  all_issues = list(draft_issues or [])
979
+ all_issues.extend(dbm_notes)
938
980
  if prevalidate_errors:
939
981
  all_issues.extend(prevalidate_errors)
940
982
 
@@ -1340,11 +1382,59 @@ class PublishModule:
1340
1382
  draft=draft_requested,
1341
1383
  )
1342
1384
 
1385
+ # Explicit --attribute-data must reach the AI payload too (it used to be
1386
+ # honored only in the manual flow, so AI-mode values were silently
1387
+ # dropped). Applied before unmatched-column binding so explicit values win.
1388
+ self._apply_attribute_data_override(product_payload)
1389
+
1343
1390
  # Bind extra Excel/CSV columns that the whitelist did not recognize,
1344
1391
  # using the category attribute definitions fetched above.
1345
- self._unmapped_columns = self._apply_unmatched_category_attributes(
1392
+ unmapped_cols = self._apply_unmatched_category_attributes(
1346
1393
  product_payload, schema, category_attributes
1347
1394
  )
1395
+ self._unmapped_columns = unmapped_cols
1396
+
1397
+ # If a provided column could not be confidently matched but plausibly
1398
+ # maps to one or more category attributes, ask the seller to confirm the
1399
+ # mapping instead of silently dropping it (which would otherwise surface
1400
+ # later as a cryptic batchValidate "missing attribute" error).
1401
+ mapping_check = self._check_unmapped_columns(unmapped_cols, product_payload)
1402
+ if mapping_check:
1403
+ return mapping_check
1404
+
1405
+ # Safety warning: honor an explicit --safety-warning in the source flow.
1406
+ # build_create_product_payload defaults the flag to "No" and manual mode
1407
+ # applies the CLI flags at build time, but the Excel/source flow never did
1408
+ # — so --safety-warning was silently ignored here. Apply it now so the
1409
+ # seller's explicit choice is never overwritten by the default.
1410
+ self._apply_safety_cli_override(product_payload)
1411
+
1412
+ # Safety guidance gate: never silently ship an inferred "No". If the seller
1413
+ # has not chosen a value but the source carries real safety content, pause
1414
+ # and ask them to confirm yes/no (or complete the Excel). The flag can only
1415
+ # be auto-populated once the seller answers, per the "surface assumed
1416
+ # values, do not silently replace" rule.
1417
+ if getattr(self.args, "safety_warning", None) is None:
1418
+ safety_evidence = self._detect_safety_content(product_payload, schema)
1419
+ if safety_evidence:
1420
+ current_flag = (product_payload.get("Attributes") or {}).get(
1421
+ "Does_this_product_have_a_safety_warning"
1422
+ )
1423
+ return {
1424
+ "status": "need_safety_warning_confirm",
1425
+ "title": i18n._("pub_safety_confirm_title"),
1426
+ "message": i18n._(
1427
+ "pub_safety_confirm_message",
1428
+ evidence=", ".join(safety_evidence),
1429
+ ),
1430
+ "detected_safety_content": safety_evidence,
1431
+ "current_value": current_flag,
1432
+ "next_actions": [
1433
+ i18n._("pub_safety_confirm_next_yes"),
1434
+ i18n._("pub_safety_confirm_next_no"),
1435
+ i18n._("pub_safety_confirm_next_excel"),
1436
+ ],
1437
+ }
1348
1438
 
1349
1439
  # Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
1350
1440
  draft_issues: list = []
@@ -1506,8 +1596,8 @@ class PublishModule:
1506
1596
 
1507
1597
  # Structural fields Miravia always requires for any product (not category
1508
1598
  # attributes, so the API never governs them) — always blocking. NOTE:
1509
- # delivery method (Attributes.deliverySof) is intentionally NOT here — it is
1510
- # auto-defaulted (DBM, or DBS when the seller has DBS permission) rather
1599
+ # delivery method (Attributes.delivery_option_sof) is intentionally NOT here — it is
1600
+ # auto-defaulted (DBM=No, or DBS=Yes when the seller has DBS permission) rather
1511
1601
  # than hard-collected; see _check_dbm_limits.
1512
1602
  _CORE_MANDATORY_KEYS = frozenset(
1513
1603
  {
@@ -1672,15 +1762,19 @@ class PublishModule:
1672
1762
  """
1673
1763
  confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
1674
1764
  if confirmed_brand_id:
1765
+ # Honor an explicitly passed --brand-name too (aligns with the
1766
+ # manual flow's _resolve_brand_interactive). Otherwise a seller who
1767
+ # confirms by ID but whose source lacks the brand name would have
1768
+ # the brand silently dropped from the payload.
1675
1769
  return {
1676
1770
  "brand_id": int(confirmed_brand_id),
1677
- "brand_name": schema.brand_name or "",
1771
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
1678
1772
  }
1679
1773
 
1680
1774
  if schema.brand_id:
1681
1775
  return {
1682
1776
  "brand_id": int(schema.brand_id),
1683
- "brand_name": schema.brand_name or "",
1777
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
1684
1778
  }
1685
1779
 
1686
1780
  brand_name = self.args.brand_name or schema.brand_name
@@ -2013,8 +2107,8 @@ class PublishModule:
2013
2107
  # jeans, so an empty EAN must NOT be flagged as missing.
2014
2108
  api_optional: List[str] = []
2015
2109
 
2016
- # Delivery method (Attributes.deliverySof) is NOT a hard-collect field:
2017
- # Miravia defaults it when absent (DBM, or DBS if the seller has DBS
2110
+ # Delivery method (Attributes.delivery_option_sof) is NOT a hard-collect field:
2111
+ # Miravia defaults it when absent (DBM=No, or DBS=Yes if the seller has DBS
2018
2112
  # permission — see _check_dbm_limits), so an empty value shows as
2019
2113
  # "Optional" here and never blocks.
2020
2114
  if not (getattr(schema, "delivery_method", None) or "").strip():
@@ -2179,8 +2273,9 @@ class PublishModule:
2179
2273
  value under the attribute's canonical key — into each SKU for
2180
2274
  ``attribute_type == 'sku'``, otherwise into the product-level
2181
2275
  ``Attributes``. Existing keys are never overwritten (whitelist/LLM
2182
- values win). Returns the headers that matched no category attribute
2183
- so the caller can surface them instead of silently dropping them.
2276
+ values win). Returns a list of ``{header, value, candidates}`` for the
2277
+ headers that matched no category attribute confidently, so the caller can
2278
+ ask the seller to confirm the mapping instead of silently dropping them.
2184
2279
 
2185
2280
  We intentionally bind ONLY the non-whitelisted columns here. Columns
2186
2281
  the whitelist already consumed (Title/Price/Image/Color/Stock/...) are
@@ -2196,11 +2291,31 @@ class PublishModule:
2196
2291
  return []
2197
2292
  attributes = product_payload.setdefault("Attributes", {})
2198
2293
  skus = (product_payload.get("Skus") or {}).get("Sku") or []
2199
- unmapped: List[str] = []
2294
+ unmapped: List[Dict[str, Any]] = []
2200
2295
  for header, value in unmatched.items():
2201
2296
  attr = self._find_category_attribute(category_attributes, (header,))
2202
2297
  if not attr:
2203
- unmapped.append(header)
2298
+ # No confident match: collect the header with fuzzy candidates so
2299
+ # the caller can ask the seller to confirm the intended attribute
2300
+ # instead of silently dropping the column.
2301
+ unmapped.append(
2302
+ {
2303
+ "header": header,
2304
+ "value": value,
2305
+ "candidates": [
2306
+ {
2307
+ "name": c.get("name"),
2308
+ "en_name": c.get("en_name"),
2309
+ "label": c.get("label") or c.get("en_name") or c.get("name"),
2310
+ "attribute_type": c.get("attribute_type") or "normal",
2311
+ "is_mandatory": str(c.get("is_mandatory", "0")),
2312
+ }
2313
+ for c in self._suggest_attribute_candidates(
2314
+ header, category_attributes
2315
+ )
2316
+ ],
2317
+ }
2318
+ )
2204
2319
  continue
2205
2320
  key = attr.get("name") or attr.get("en_name") or header
2206
2321
  if (attr.get("attribute_type") or "normal") == "sku":
@@ -2212,11 +2327,122 @@ class PublishModule:
2212
2327
  attributes[key] = value
2213
2328
  if unmapped:
2214
2329
  logger.info(
2215
- "Excel/CSV columns not matching any category attribute: %s",
2216
- ", ".join(unmapped),
2330
+ "Excel/CSV columns not confidently matched: %s",
2331
+ ", ".join(u["header"] for u in unmapped),
2217
2332
  )
2218
2333
  return unmapped
2219
2334
 
2335
+ def _suggest_attribute_candidates(
2336
+ self,
2337
+ header: str,
2338
+ category_attributes: List[Dict[str, Any]],
2339
+ max_candidates: int = 8,
2340
+ ) -> List[Dict[str, Any]]:
2341
+ """Fuzzy-suggest category attributes a free-text column header might map
2342
+ to — for the confirmation prompt only, never auto-bound.
2343
+
2344
+ Uses a shared-prefix (stem) heuristic so e.g. header ``packaging``
2345
+ suggests ``Package_Label_Photo_EU``. Deliberately looser than
2346
+ ``_find_category_attribute`` because a human confirms the result.
2347
+ """
2348
+ def _words(text: Any) -> List[str]:
2349
+ return [w for w in re.split(r"[^a-z0-9]+", str(text).lower()) if len(w) >= 4]
2350
+
2351
+ header_words = set(_words(header))
2352
+ h_sq = self._squash_attr_name(header)
2353
+ if h_sq:
2354
+ header_words.add(h_sq)
2355
+ out: List[Dict[str, Any]] = []
2356
+ for attr in category_attributes or []:
2357
+ attr_words: List[str] = []
2358
+ for key in ("name", "en_name", "label", "attribute_name"):
2359
+ if attr.get(key):
2360
+ attr_words.extend(_words(attr.get(key)))
2361
+ attr_words.append(self._squash_attr_name(attr.get(key)))
2362
+ hit = False
2363
+ for hw in header_words:
2364
+ for aw in attr_words:
2365
+ if (
2366
+ len(hw) >= 5
2367
+ and len(aw) >= 5
2368
+ and hw[:5] == aw[:5]
2369
+ ) or hw in aw or aw in hw:
2370
+ hit = True
2371
+ break
2372
+ if hit:
2373
+ break
2374
+ if hit:
2375
+ out.append(attr)
2376
+ if len(out) >= max_candidates:
2377
+ break
2378
+ return out
2379
+
2380
+ def _apply_attribute_data_override(self, product_payload: Dict[str, Any]) -> None:
2381
+ """Merge ``--attribute-data`` into the AI-built payload.
2382
+
2383
+ The AI flow builds Attributes/Skus from the extracted schema and never
2384
+ consulted ``--attribute-data``, so values passed there were silently
2385
+ dropped. ``--attribute-data`` is the seller's own explicit input, so it
2386
+ overrides extracted values; SKU-level keys are merged into every SKU.
2387
+ """
2388
+ raw = getattr(self.args, "attribute_data", None)
2389
+ if not raw:
2390
+ return
2391
+ try:
2392
+ data = json.loads(raw)
2393
+ except json.JSONDecodeError as exc:
2394
+ raise ValueError(f"--attribute-data is not valid JSON: {exc}") from exc
2395
+ if not isinstance(data, dict):
2396
+ raise ValueError("--attribute-data must be a JSON object")
2397
+ attributes = product_payload.setdefault("Attributes", {})
2398
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2399
+ for key, value in data.items():
2400
+ if key in _SKU_LEVEL_ATTRIBUTE_NAMES:
2401
+ for sku in skus:
2402
+ sku[key] = value
2403
+ else:
2404
+ attributes[key] = value
2405
+
2406
+ def _check_unmapped_columns(
2407
+ self,
2408
+ unmapped_cols: List[Dict[str, Any]],
2409
+ product_payload: Dict[str, Any],
2410
+ ) -> Optional[Dict[str, Any]]:
2411
+ """Return a ``need_attribute_mapping`` prompt when an unmapped column
2412
+ looks like a category attribute the seller intended, so they confirm the
2413
+ mapping instead of us dropping it silently (which would later surface as
2414
+ a cryptic batchValidate "missing attribute" error). Columns with no
2415
+ plausible candidate — or whose candidates are already filled via
2416
+ ``--attribute-data`` — are ignored.
2417
+ """
2418
+ attributes = product_payload.get("Attributes") or {}
2419
+ skus = (product_payload.get("Skus") or {}).get("Sku") or []
2420
+
2421
+ def _filled(cand: Dict[str, Any]) -> bool:
2422
+ names = [cand.get("name"), cand.get("en_name")]
2423
+
2424
+ def _present(container: Dict[str, Any]) -> bool:
2425
+ return any(n and str(container.get(n) or "").strip() for n in names)
2426
+
2427
+ if (cand.get("attribute_type") or "normal") == "sku":
2428
+ return bool(skus) and all(_present(s) for s in skus)
2429
+ return _present(attributes)
2430
+
2431
+ need: List[Dict[str, Any]] = []
2432
+ for item in unmapped_cols or []:
2433
+ open_cands = [c for c in (item.get("candidates") or []) if not _filled(c)]
2434
+ if open_cands:
2435
+ need.append({**item, "candidates": open_cands})
2436
+ if not need:
2437
+ return None
2438
+ return {
2439
+ "status": "need_attribute_mapping",
2440
+ "title": i18n._("pub_need_attribute_mapping_title"),
2441
+ "message": i18n._("pub_need_attribute_mapping_description"),
2442
+ "unmapped_columns": need,
2443
+ "next_actions": [i18n._("pub_need_attribute_mapping_next")],
2444
+ }
2445
+
2220
2446
  def _field_is_required(
2221
2447
  self,
2222
2448
  field_key: str,
@@ -2503,7 +2729,7 @@ class PublishModule:
2503
2729
  confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
2504
2730
  if confirmed_brand_id:
2505
2731
  return {
2506
- "brand_name": schema.brand_name or "",
2732
+ "brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
2507
2733
  "brand_id": int(confirmed_brand_id),
2508
2734
  }
2509
2735
 
@@ -2642,7 +2868,11 @@ class PublishModule:
2642
2868
 
2643
2869
  Per the Miravia ``/product/create`` documentation:
2644
2870
 
2645
- - ``SellerSku`` is mandatory for every SKU.
2871
+ - ``SellerSku`` is NOT hard-coded mandatory: whether it must be filled is
2872
+ governed by the category attribute API and enforced once in
2873
+ :meth:`_validate_create_payload` (via ``seller_sku_required``), which
2874
+ applies to both Draft and online publishes. Here we simply omit an
2875
+ empty SellerSku so ``/product/create`` can auto-generate a value.
2646
2876
  - Package dimensions/weight are per-SKU fields
2647
2877
  (``package_length/width/height/weight``).
2648
2878
  - Sales properties are flat SKU fields, not a nested ``Variation``
@@ -2671,11 +2901,12 @@ class PublishModule:
2671
2901
  seller_sku = raw.get("seller_sku") or (
2672
2902
  schema.seller_skus[idx] if idx < len(schema.seller_skus) else None
2673
2903
  )
2674
- if not seller_sku:
2675
- raise ValueError(
2676
- i18n._("pub_seller_sku_required", idx=idx + 1)
2677
- )
2678
- sku["SellerSku"] = seller_sku
2904
+ # Do NOT hard-require SellerSku here. Its mandatory-ness comes from
2905
+ # the category attribute API and is enforced in _validate_create_payload
2906
+ # (draft-aware: a live publish blocks an interface-mandatory empty value;
2907
+ # a draft defers it). Omit empty values so create can auto-generate.
2908
+ if seller_sku:
2909
+ sku["SellerSku"] = seller_sku
2679
2910
 
2680
2911
  ean_code = raw.get("ean_code") or (
2681
2912
  schema.ean_codes[idx] if idx < len(schema.ean_codes) else None
@@ -2721,6 +2952,25 @@ class PublishModule:
2721
2952
  if sku_images:
2722
2953
  sku["Images"] = {"Image": sku_images}
2723
2954
 
2955
+ # Country-specific prices live INSIDE each SKU (per the
2956
+ # authoritative /product/create contract), with capitalised keys
2957
+ # ``CountryCode`` / ``Price`` / ``SalePrice``. ``country_general_prices``
2958
+ # supplies the per-country Price and ``country_discounted_prices``
2959
+ # the per-country SalePrice. A product-level list or the old
2960
+ # lowercase ``{"country","price"}`` shape is silently ignored.
2961
+ _cgp = schema.country_general_prices or {}
2962
+ _cdp = schema.country_discounted_prices or {}
2963
+ _country_price = []
2964
+ for _cc in sorted(set(_cgp) | set(_cdp)):
2965
+ _entry: Dict[str, Any] = {"CountryCode": _cc}
2966
+ if _cgp.get(_cc) not in (None, ""):
2967
+ _entry["Price"] = str(_cgp[_cc])
2968
+ if _cdp.get(_cc) not in (None, ""):
2969
+ _entry["SalePrice"] = str(_cdp[_cc])
2970
+ _country_price.append(_entry)
2971
+ if _country_price:
2972
+ sku["CountryPrice"] = _country_price
2973
+
2724
2974
  skus.append(sku)
2725
2975
 
2726
2976
  if len(skus) > _MAX_SKUS_PER_PRODUCT:
@@ -2729,6 +2979,84 @@ class PublishModule:
2729
2979
  )
2730
2980
  return skus
2731
2981
 
2982
+ # Boilerplate default warning text — must NOT be treated as real safety content.
2983
+ _SAFETY_CONTENT_DEFAULT = "no specific safety warnings apply to this product"
2984
+
2985
+ def _apply_safety_cli_override(self, product_payload: Dict[str, Any]) -> None:
2986
+ """Honor ``--safety-warning`` / ``--safety-warning-content`` in the source flow.
2987
+
2988
+ ``build_create_product_payload`` defaults ``Does_this_product_have_a_safety_warning``
2989
+ to "No" when ``schema.safety_warning`` is falsy, and manual mode applies the CLI
2990
+ flags at build time (see the manual path). The Excel/source flow never applied
2991
+ them, so ``--safety-warning`` was silently ignored there. Write the seller's
2992
+ explicit choice here using the same "Yes"/"No" casing the client already emits,
2993
+ so the platform contract is unchanged and only the previously-ignored flag is
2994
+ now respected.
2995
+ """
2996
+ safety_arg = getattr(self.args, "safety_warning", None)
2997
+ if safety_arg is None:
2998
+ return
2999
+ is_yes = str(safety_arg).strip().lower() in ("yes", "1", "true", "y")
3000
+ attributes = product_payload.setdefault("Attributes", {})
3001
+ value = "Yes" if is_yes else "No"
3002
+ attributes["Does this product have a safety warning"] = value
3003
+ attributes["Does_this_product_have_a_safety_warning"] = value
3004
+ content = getattr(self.args, "safety_warning_content", None)
3005
+ if is_yes and content:
3006
+ attributes["Safety_warning_content"] = content
3007
+
3008
+ def _detect_safety_content(
3009
+ self, product_payload: Dict[str, Any], schema: MiraviaListingSchema
3010
+ ) -> List[str]:
3011
+ """Return human-readable evidence that the product has safety info.
3012
+
3013
+ Used to decide whether the auto-defaulted safety flag should be surfaced
3014
+ to the seller instead of shipped silently. Ignores the boilerplate default
3015
+ warning text and a plain "No" flag so we do not raise false prompts. Counts
3016
+ real safety artifacts that came from the source: safety documents
3017
+ (Safety_Date_Sheet / CPSR / CPNP URLs), an affirmative Hazmat flag, a
3018
+ non-default ``Safety_warning_content`` already present, or an explicit
3019
+ ``Does_this_product_have_a_safety_warning=Yes``.
3020
+ """
3021
+ attributes = product_payload.get("Attributes") or {}
3022
+ evidence: List[str] = []
3023
+ for key, val in attributes.items():
3024
+ if not isinstance(val, str) or not val.strip():
3025
+ continue
3026
+ low = key.lower()
3027
+ # Skip the yes/no flag itself (handled explicitly below).
3028
+ if low in (
3029
+ "does this product have a safety warning",
3030
+ "does_this_product_have_a_safety_warning",
3031
+ ):
3032
+ if val.strip().lower() == "yes":
3033
+ evidence.append(f"{key}=Yes")
3034
+ continue
3035
+ if not any(tok in low for tok in ("safety", "cpsr", "cpnp", "hazmat", "warning")):
3036
+ continue
3037
+ # Boilerplate default content is not evidence.
3038
+ if self._SAFETY_CONTENT_DEFAULT in val.strip().lower():
3039
+ continue
3040
+ # A negative/empty Hazmat flag is not evidence.
3041
+ if "hazmat" in low and val.strip().lower() in ("no", "false", "0", "n/a", "-"):
3042
+ continue
3043
+ # Null-like placeholder strings from empty cells are not evidence.
3044
+ if val.strip().lower() in ("none", "null", "n/a", "na", "-"):
3045
+ continue
3046
+ display = val if len(val) <= 60 else val[:57] + "..."
3047
+ evidence.append(f"{key}={display}")
3048
+ sc = getattr(schema, "safety_warning_content", None)
3049
+ if sc and self._SAFETY_CONTENT_DEFAULT not in str(sc).strip().lower():
3050
+ evidence.append("safety_warning_content provided")
3051
+ # Deduplicate while preserving order.
3052
+ seen: set = set()
3053
+ uniq: List[str] = []
3054
+ for item in evidence:
3055
+ if item not in seen:
3056
+ seen.add(item)
3057
+ uniq.append(item)
3058
+ return uniq
3059
+
2732
3060
  def _run_prevalidate(
2733
3061
  self, product_payload: Dict[str, Any]
2734
3062
  ) -> Optional[Dict[str, Any]]:
@@ -3186,10 +3514,11 @@ class PublishModule:
3186
3514
  processed["SellerSku"] = processed.pop("seller_sku")
3187
3515
  elif "seller_sku" in processed:
3188
3516
  processed.pop("seller_sku") # SellerSku already set
3517
+ # SellerSku is optional (governed by the category attribute API and
3518
+ # enforced in _validate_create_payload, not relaxed by draft). When
3519
+ # empty, drop the key so /product/create can auto-generate it.
3189
3520
  if not processed.get("SellerSku"):
3190
- raise ValueError(
3191
- i18n._("pub_seller_sku_required", idx=idx + 1)
3192
- )
3521
+ processed.pop("SellerSku", None)
3193
3522
  # Migrate SKU-level images
3194
3523
  sku_images = processed.get("Images", {}).get("Image", [])
3195
3524
  if sku_images:
@@ -3333,6 +3662,7 @@ def _validate_title(title: str) -> None:
3333
3662
  def _validate_create_payload(
3334
3663
  payload: Dict[str, Any],
3335
3664
  seller_sku_required: bool = False,
3665
+ draft: bool = False,
3336
3666
  ) -> None:
3337
3667
  """Validate a Miravia ``/product/create`` Product payload.
3338
3668
 
@@ -3342,7 +3672,9 @@ def _validate_create_payload(
3342
3672
 
3343
3673
  ``seller_sku_required`` is decided by the caller from the category attribute
3344
3674
  API (``_field_is_required``): when False, an empty SellerSku is allowed and
3345
- the create API auto-generates a unique value.
3675
+ the create API auto-generates a unique value. When True but ``draft`` is set,
3676
+ the empty value is deferred to the draft (surfaced as a draft_issue) rather
3677
+ than hard-blocked, consistent with P7 — only a live publish enforces it.
3346
3678
  """
3347
3679
  attributes = payload.get("Attributes") or {}
3348
3680
  title = attributes.get("name")
@@ -3386,7 +3718,11 @@ def _validate_create_payload(
3386
3718
  raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
3387
3719
 
3388
3720
  for idx, sku in enumerate(skus, start=1):
3389
- if seller_sku_required and not (sku.get("SellerSku") or sku.get("seller_sku")):
3721
+ if (
3722
+ seller_sku_required
3723
+ and not draft
3724
+ and not (sku.get("SellerSku") or sku.get("seller_sku"))
3725
+ ):
3390
3726
  raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
3391
3727
 
3392
3728
  price = sku.get("price")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miravia_test/miravia-publish-skill",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Miravia Product Publish Skill installer — one command to install the Miravia AI product listing skill into any AI Agent (Qoder / Cursor / Claude Code). Upload materials and let the AI extract data and publish products automatically.",
5
5
  "bin": {
6
6
  "miravia-publish-skill": "bin/cli.js"