All checks were successful
技能自动化发布 / release (push) Successful in 4s
Align scaffold slug replacement, Action manifest strict boundaries, metadata prune API, field permission validation, and task_logs readonly semantics with documented template contracts. Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import unittest
|
|
|
|
from _support import get_skill_root
|
|
|
|
from cli.app import build_parser
|
|
from util.constants import SKILL_SLUG
|
|
|
|
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
|
|
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
|
|
_RESERVED_PLACEMENTS = frozenset({"row", "batch"})
|
|
_RESERVED_ACTION_FIELDS = frozenset({"bind", "concurrency", "locks"})
|
|
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
|
|
_REQUIRED_ACTION_KEYS = frozenset({"id", "label", "description", "placements", "entrypoint"})
|
|
_FORBIDDEN_BUSINESS_TERMS = (
|
|
"wechat",
|
|
"微信",
|
|
"contacts",
|
|
"wxid",
|
|
"account_wxid",
|
|
"contact.db",
|
|
"scrape-contacts",
|
|
"douyin",
|
|
"抖音",
|
|
"xiaohongshu",
|
|
"小红书",
|
|
"公众号",
|
|
"logistics",
|
|
"物流",
|
|
)
|
|
|
|
_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
|
|
|
|
|
|
def _load_actions_manifest() -> dict:
|
|
path = os.path.join(get_skill_root(), "assets", "actions.json")
|
|
with open(path, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _cli_subcommands() -> set[str]:
|
|
parser = build_parser()
|
|
subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined]
|
|
return set(subs.keys())
|
|
|
|
|
|
def _collect_template_vars(args: list) -> set[str]:
|
|
found: set[str] = set()
|
|
for item in args:
|
|
if not isinstance(item, str):
|
|
continue
|
|
for match in _TEMPLATE_ARG_PATTERN.finditer(item):
|
|
found.add(match.group(1))
|
|
return found
|
|
|
|
|
|
def _load_actions_schema() -> dict:
|
|
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
|
|
with open(path, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
class TestActionsManifest(unittest.TestCase):
|
|
def test_manifest_exists_and_schema(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
self.assertEqual(manifest.get("schemaVersion"), 1)
|
|
skill = manifest.get("skill")
|
|
self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG))
|
|
self.assertIsInstance(manifest.get("actions"), list)
|
|
self.assertGreater(len(manifest["actions"]), 0)
|
|
|
|
def test_action_ids_unique_and_cli_valid(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
subs = _cli_subcommands()
|
|
seen: set[str] = set()
|
|
for action in manifest["actions"]:
|
|
action_id = action["id"]
|
|
self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}")
|
|
seen.add(action_id)
|
|
|
|
self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty")
|
|
self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty")
|
|
|
|
entry = action["entrypoint"]
|
|
self.assertEqual(entry["type"], "cli")
|
|
self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI")
|
|
|
|
args = entry.get("args")
|
|
self.assertIsInstance(args, list, msg=f"{action_id} args must be array")
|
|
for item in args:
|
|
self.assertIsInstance(
|
|
item,
|
|
(str, int, float, bool),
|
|
msg=f"{action_id} arg item must be scalar",
|
|
)
|
|
self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object")
|
|
self.assertIsNotNone(item, msg=f"{action_id} arg must not be null")
|
|
|
|
placements = action.get("placements")
|
|
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
|
|
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
|
|
for placement in placements:
|
|
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
|
|
|
|
def test_template_example_does_not_use_reserved_placements(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
placements = set(action.get("placements") or [])
|
|
overlap = placements & _RESERVED_PLACEMENTS
|
|
self.assertFalse(
|
|
overlap,
|
|
msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}",
|
|
)
|
|
|
|
def test_template_variables_have_input_schema(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
args = action.get("entrypoint", {}).get("args") or []
|
|
template_vars = _collect_template_vars(args)
|
|
if not template_vars:
|
|
continue
|
|
schema = action.get("inputSchema") or {}
|
|
properties = schema.get("properties") or {}
|
|
for var in template_vars:
|
|
self.assertIn(
|
|
var,
|
|
properties,
|
|
msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it",
|
|
)
|
|
|
|
def test_required_fields_exist_in_properties(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
schema = action.get("inputSchema") or {}
|
|
required = schema.get("required") or []
|
|
properties = schema.get("properties") or {}
|
|
for field in required:
|
|
self.assertIn(
|
|
field,
|
|
properties,
|
|
msg=f"{action['id']} required field {field!r} missing from properties",
|
|
)
|
|
|
|
def test_sensitive_params_not_on_toolbar(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
props = (action.get("inputSchema") or {}).get("properties") or {}
|
|
has_sensitive = any(p.get("sensitive") for p in props.values())
|
|
if has_sensitive and "toolbar" in (action.get("placements") or []):
|
|
self.fail(f"{action['id']} has sensitive input but is on toolbar")
|
|
|
|
def test_confirmation_message_structure(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
confirmation = action.get("confirmation")
|
|
if confirmation is None:
|
|
continue
|
|
self.assertIsInstance(confirmation, dict)
|
|
message = confirmation.get("message")
|
|
self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty")
|
|
|
|
def test_manifest_has_no_specific_business_skill_names(self) -> None:
|
|
path = os.path.join(get_skill_root(), "assets", "actions.json")
|
|
with open(path, encoding="utf-8") as f:
|
|
raw = f.read().lower()
|
|
for term in _FORBIDDEN_BUSINESS_TERMS:
|
|
self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}")
|
|
|
|
def test_actions_md_exists_and_links_manifest(self) -> None:
|
|
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
|
|
self.assertTrue(os.path.isfile(path))
|
|
with open(path, encoding="utf-8") as f:
|
|
text = f.read()
|
|
self.assertIn("actions.json", text)
|
|
self.assertIn("schemaVersion", text)
|
|
for term in _FORBIDDEN_BUSINESS_TERMS:
|
|
self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}")
|
|
|
|
def test_skill_actions_schema_file_exists(self) -> None:
|
|
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
|
|
self.assertTrue(os.path.isfile(path))
|
|
with open(path, encoding="utf-8") as f:
|
|
schema = json.load(f)
|
|
self.assertEqual(schema.get("title"), "SkillActionManifest")
|
|
self.assertIn("actions", schema.get("properties", {}))
|
|
|
|
def test_skill_actions_schema_json_parses(self) -> None:
|
|
schema = _load_actions_schema()
|
|
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
|
|
self.assertTrue(schema.get("additionalProperties") is False)
|
|
self.assertIn("action", schema.get("$defs", {}))
|
|
|
|
def test_template_manifest_matches_schema_required_keys(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
schema = _load_actions_schema()
|
|
for key in _REQUIRED_MANIFEST_KEYS:
|
|
self.assertIn(key, manifest)
|
|
self.assertIn(key, schema.get("properties", {}))
|
|
for action in manifest["actions"]:
|
|
for key in _REQUIRED_ACTION_KEYS:
|
|
self.assertIn(key, action)
|
|
|
|
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
|
|
manifest = _load_actions_manifest()
|
|
for action in manifest["actions"]:
|
|
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
|
|
self.assertFalse(
|
|
overlap,
|
|
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
|
|
)
|
|
|
|
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
|
|
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
|
|
with open(path, encoding="utf-8") as f:
|
|
text = f.read()
|
|
for marker in (
|
|
"严格规范",
|
|
"向后兼容",
|
|
"Phase 1",
|
|
"bind",
|
|
"concurrency",
|
|
"locks",
|
|
"row",
|
|
"batch",
|
|
"additionalProperties",
|
|
):
|
|
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|