diff --git a/features/skill_schema.feature b/features/skill_schema.feature index e09d9d4f0..ae3cbf2ef 100644 --- a/features/skill_schema.feature +++ b/features/skill_schema.feature @@ -310,3 +310,63 @@ Feature: Skill YAML schema validation And the skill config model_dump should contain key "mcp_servers" And the skill config model_dump should contain key "agent_skill_folders" + + # ──────────────────────────────────────────────────────────── + # skill: wrapper key scenarios + # ──────────────────────────────────────────────────────────── + @tdd_issue + @tdd_issue_1472 + Scenario: Load spec-compliant YAML with skill: wrapper key + Given a spec-compliant skill YAML with skill: wrapper key + When I validate the skill schema + Then the skill schema validation should succeed + And the skill config name should be "local/wrapped-skill" + And the skill config should have 1 tools + + @tdd_issue + @tdd_issue_1472 + Scenario: Load spec-compliant YAML with cleveragents: header and skill: wrapper + Given a spec-compliant skill YAML with cleveragents: header and skill: wrapper + When I validate the skill schema + Then the skill schema validation should succeed + And the skill config name should be "local/wrapped-with-meta" + + @tdd_issue + @tdd_issue_1472 + Scenario: skill: wrapper key with None value raises ValueError + Given a skill YAML with skill: wrapper key with None value + When I validate the skill schema expecting failure + Then the skill schema validation should fail + And the skill schema error should mention "empty" + + @tdd_issue + @tdd_issue_1472 + Scenario: skill: wrapper key with non-dict value raises ValueError + Given a skill YAML with skill: wrapper key and string value + When I validate the skill schema expecting failure + Then the skill schema validation should fail + And the skill schema error should mention "mapping" + + @tdd_issue + @tdd_issue_1472 + Scenario: skill: wrapper key with list value raises ValueError + Given a skill YAML with skill: wrapper key and list value + When I validate the skill schema expecting failure + Then the skill schema validation should fail + And the skill schema error should mention "mapping" + + @tdd_issue + @tdd_issue_1472 + Scenario: Flat YAML without wrapper key still works (backward compatibility) + Given a skill YAML string with only the name field + When I validate the skill schema + Then the skill schema validation should succeed + And the skill config name should be "local/empty-skill" + + @tdd_issue + @tdd_issue_1472 + Scenario: cleveragents: header alone (flat format with metadata, no skill: wrapper) + Given a skill YAML with cleveragents: header and flat format + When I validate the skill schema + Then the skill schema validation should succeed + And the skill config name should be "local/flat-with-meta" diff --git a/features/steps/skill_schema_steps.py b/features/steps/skill_schema_steps.py index d6986245c..4cc7e3c73 100644 --- a/features/steps/skill_schema_steps.py +++ b/features/steps/skill_schema_steps.py @@ -651,3 +651,82 @@ def step_then_model_dump_contains_key(context: Context, key: str) -> None: assert context.skill_config is not None data: dict[str, Any] = context.skill_config.model_dump() assert key in data, f"Key '{key}' not found in model_dump: {list(data.keys())}" + + +# ──────────────────────────────────────────────────────────── +# skill: wrapper key test fixtures and steps +# ──────────────────────────────────────────────────────────── + +_SPEC_COMPLIANT_WRAPPED_YAML = """\ +cleveragents: + version: "1.0" +skill: + name: local/wrapped-skill + tools: + - name: builtin/shell_execute +""" + +_SPEC_COMPLIANT_WRAPPED_META_YAML = """\ +cleveragents: + version: "2.0" +skill: + name: local/wrapped-with-meta + description: "A wrapped skill with metadata" +""" + +_SKILL_WRAPPER_NONE_YAML = """\ +skill: +""" + +_SKILL_WRAPPER_STRING_YAML = """\ +skill: "just a string" +""" + +_SKILL_WRAPPER_LIST_YAML = """\ +skill: + - item1 + - item2 +""" + +_CLEVERAGENTS_FLAT_META_YAML = """\ +cleveragents: + version: "1.0" +name: local/flat-with-meta +description: "Flat config with stray metadata" +""" + + +@given("a spec-compliant skill YAML with skill: wrapper key") +def step_given_spec_compliant_wrapper(context: Context) -> None: + """Provide a spec-compliant YAML with cleveragents: header and skill: wrapper.""" + context.skill_yaml_string = _SPEC_COMPLIANT_WRAPPED_YAML + + +@given("a spec-compliant skill YAML with cleveragents: header and skill: wrapper") +def step_given_spec_compliant_with_meta(context: Context) -> None: + """Provide a spec-compliant YAML with both cleveragents: header and skill: wrapper.""" + context.skill_yaml_string = _SPEC_COMPLIANT_WRAPPED_META_YAML + + +@given("a skill YAML with skill: wrapper key with None value") +def step_given_skill_wrapper_none(context: Context) -> None: + """Provide a YAML with skill: wrapper key but no value (None).""" + context.skill_yaml_string = _SKILL_WRAPPER_NONE_YAML + + +@given("a skill YAML with skill: wrapper key and string value") +def step_given_skill_wrapper_string(context: Context) -> None: + """Provide a YAML with skill: wrapper key but a non-dict string value.""" + context.skill_yaml_string = _SKILL_WRAPPER_STRING_YAML + + +@given("a skill YAML with skill: wrapper key and list value") +def step_given_skill_wrapper_list(context: Context) -> None: + """Provide a YAML with skill: wrapper key but a non-dict list value.""" + context.skill_yaml_string = _SKILL_WRAPPER_LIST_YAML + + +@given("a skill YAML with cleveragents: header and flat format") +def step_given_cleveragents_flat_meta(context: Context) -> None: + """Provide a flat-format YAML with cleveragents: metadata but no skill: wrapper.""" + context.skill_yaml_string = _CLEVERAGENTS_FLAT_META_YAML diff --git a/robot/skill_schema.robot b/robot/skill_schema.robot index 0e4d7457c..33794fa15 100644 --- a/robot/skill_schema.robot +++ b/robot/skill_schema.robot @@ -49,3 +49,21 @@ Reject Invalid Skill YAML Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} skill-schema-expected-fail +Validate Spec-Compliant Skill YAML With skill: Wrapper Key + [Tags] tdd_issue tdd_issue_1472 + [Documentation] Parse a spec-compliant YAML with cleveragents: header and skill: wrapper key + ${wrapped_yaml}= Set Variable ${TEMPDIR}${/}wrapped_skill.yaml + ${yaml_content}= Catenate SEPARATOR=\n + ... cleveragents: + ... ${SPACE}${SPACE}version: "1.0" + ... skill: + ... ${SPACE}${SPACE}name: local/wrapped-skill + ... ${SPACE}${SPACE}description: "A wrapped skill" + ... + Create File ${wrapped_yaml} ${yaml_content} + ${result}= Run Process ${PYTHON} ${HELPER} validate ${wrapped_yaml} cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} skill-schema-ok + diff --git a/src/cleveragents/skills/schema.py b/src/cleveragents/skills/schema.py index 7b851b040..8bf107c17 100644 --- a/src/cleveragents/skills/schema.py +++ b/src/cleveragents/skills/schema.py @@ -6,6 +6,8 @@ Provides :class:`SkillConfigSchema`, a Pydantic model that: * Normalizes camelCase keys to snake_case before validation. * Interpolates ``${ENV_VAR}`` placeholders from environment variables. * Produces clear, actionable error messages for every validation failure. +* Handles the spec-required ``skill:`` wrapper key and ``cleveragents:`` + metadata block for forward-compatible YAML parsing. Schema definition lives in ``docs/schema/skill.schema.yaml``. Example configs live under ``examples/skills/``. @@ -392,6 +394,30 @@ class SkillConfigSchema(BaseModel): def from_yaml(cls, yaml_string: str) -> SkillConfigSchema: """Parse and validate a skill YAML string. + Handles both the spec-required YAML format with a top-level + ``skill:`` wrapper key and optional ``cleveragents:`` metadata + block, as well as the legacy flat format for backward + compatibility. + + Spec-compliant YAML: + + .. code-block:: yaml + + cleveragents: + version: "1.0" + skill: + name: local/my-skill + tools: + - name: builtin/shell_execute + + Flat (legacy) YAML, also supported: + + .. code-block:: yaml + + name: local/my-skill + tools: + - name: builtin/shell_execute + Args: yaml_string: Raw YAML content. @@ -399,7 +425,8 @@ class SkillConfigSchema(BaseModel): Validated ``SkillConfigSchema`` instance. Raises: - ValueError: If the YAML is not a mapping or is empty. + ValueError: If the YAML is not a mapping or is empty, + or if ``skill:`` has an invalid value. pydantic.ValidationError: If schema validation fails. """ if yaml_string is None: @@ -415,7 +442,35 @@ class SkillConfigSchema(BaseModel): f"Skill YAML must be a mapping (key: value), got {type(raw).__name__}." ) + # ── Normalize camelCase keys ──────────────────────────── normalized = _normalize_keys(raw) + + # ── Strip optional cleveragents metadata header ───────── + normalized.pop("cleveragents", None) + + # ── Unwrap skill: wrapper key if present ──────────────── + if "skill" in normalized: + wrapper = normalized.pop("skill") + if wrapper is None: + raise ValueError( + "skill: key is present but empty. " + "Provide a skill mapping with at least a 'name' field." + ) + if not isinstance(wrapper, dict): + raise ValueError( + f"skill: key must contain a mapping (dict), " + f"got {type(wrapper).__name__}. " + "Wrap the skill configuration as: skill:\\n " + ) + # Merge wrapper content with normalized keys. + # Wrapper values take precedence so the spec-compliant + # format works reliably even if both formats overlap. + wrapper_dict: dict[str, Any] = wrapper + for key, value in wrapper_dict.items(): + snake_key = _CAMEL_TO_SNAKE.get(key, key) + normalized[snake_key] = value + + # ── Interpolate environment variables ─────────────────── interpolated = _interpolate_env_vars(normalized) return cls.model_validate(interpolated)