|
10 | 10 | SkillConflictError, |
11 | 11 | SkillPoolService, |
12 | 12 | SkillService, |
| 13 | + _validate_skill_content, |
13 | 14 | get_workspace_skills_dir, |
14 | 15 | list_workspaces, |
15 | 16 | read_skill_pool_manifest, |
|
21 | 22 | import_pool_skill_from_hub, |
22 | 23 | install_skill_from_hub, |
23 | 24 | ) |
| 25 | +from ..agents.utils.file_handling import read_text_file_with_encoding_fallback |
24 | 26 | from ..config import load_config |
25 | 27 | from ..constant import WORKING_DIR |
26 | | -from ..security.skill_scanner import SkillScanError |
| 28 | +from ..exceptions import SkillsError |
| 29 | +from ..security.skill_scanner import SkillScanError, scan_skill_directory |
27 | 30 | from .utils import prompt_checkbox, prompt_confirm |
28 | 31 |
|
29 | 32 |
|
@@ -104,6 +107,57 @@ def _print_skill_changes( |
104 | 107 | ) |
105 | 108 |
|
106 | 109 |
|
| 110 | +def _validate_skill_frontmatter(skill_dir: Path) -> None: |
| 111 | + """Validate required skill metadata.""" |
| 112 | + skill_md = skill_dir / "SKILL.md" |
| 113 | + if not skill_md.is_file(): |
| 114 | + raise click.ClickException(f"Missing SKILL.md: {skill_md}") |
| 115 | + |
| 116 | + content = read_text_file_with_encoding_fallback(skill_md) |
| 117 | + try: |
| 118 | + _validate_skill_content(content) |
| 119 | + except SkillsError as exc: |
| 120 | + raise click.ClickException(str(exc)) |
| 121 | + except Exception as exc: |
| 122 | + raise click.ClickException( |
| 123 | + f"SKILL.md frontmatter is invalid: {exc}", |
| 124 | + ) from exc |
| 125 | + |
| 126 | + |
| 127 | +def _resolve_skill_test_dir(skill: str, agent_id: str) -> Path: |
| 128 | + """Resolve a skill argument as a path first, then workspace skill name.""" |
| 129 | + candidate = Path(skill).expanduser() |
| 130 | + if candidate.exists(): |
| 131 | + return candidate.resolve() |
| 132 | + |
| 133 | + working_dir = _get_agent_workspace(agent_id) |
| 134 | + return get_workspace_skills_dir(working_dir) / skill |
| 135 | + |
| 136 | + |
| 137 | +def _run_skill_test(skill_dir: Path) -> str: |
| 138 | + """Run local skill validation and security scanning.""" |
| 139 | + if not skill_dir.is_dir(): |
| 140 | + raise click.ClickException(f"Skill directory not found: {skill_dir}") |
| 141 | + |
| 142 | + skill_name = skill_dir.name |
| 143 | + _validate_skill_frontmatter(skill_dir) |
| 144 | + try: |
| 145 | + result = scan_skill_directory( |
| 146 | + skill_dir, |
| 147 | + skill_name=skill_name, |
| 148 | + block=True, |
| 149 | + ) |
| 150 | + except SkillScanError as exc: |
| 151 | + raise click.ClickException(str(exc)) from exc |
| 152 | + |
| 153 | + if result is not None and not result.is_safe: |
| 154 | + raise click.ClickException( |
| 155 | + "Security scan found " |
| 156 | + f"{len(result.findings)} issue(s) in skill '{skill_name}'.", |
| 157 | + ) |
| 158 | + return skill_name |
| 159 | + |
| 160 | + |
107 | 161 | def _apply_skill_changes( |
108 | 162 | skill_service: SkillService, |
109 | 163 | pool_service: SkillPoolService | None, |
@@ -489,3 +543,18 @@ def uninstall_cmd( |
489 | 543 | raise |
490 | 544 | except Exception as exc: |
491 | 545 | raise click.ClickException(str(exc)) from exc |
| 546 | + |
| 547 | + |
| 548 | +@skills_group.command("test") |
| 549 | +@click.argument("skill", required=True) |
| 550 | +@click.option( |
| 551 | + "--agent-id", |
| 552 | + default="default", |
| 553 | + help="Agent ID (defaults to 'default')", |
| 554 | +) |
| 555 | +def test_cmd(skill: str, agent_id: str) -> None: |
| 556 | + """Validate a workspace skill or local skill directory.""" |
| 557 | + skill_dir = _resolve_skill_test_dir(skill, agent_id) |
| 558 | + skill_name = _run_skill_test(skill_dir) |
| 559 | + click.echo(f"Skill test passed: {skill_name}") |
| 560 | + click.echo(f"Path: {skill_dir}") |
0 commit comments