Unify skills CLI with runtime helpers
Delegate skill discovery, parsing, lookup, search, and validation to helpers.skills so CLI behavior cannot drift from the runtime contract. Generate canonical triggers metadata and cover valid and incomplete skill validation.
Alessandro committed
Jul 17, 2026 at 04:46 UTC
9b829a04258eaa1cef878b3c7f1cbfad32e0d84b
5 files changed
+87
-149
helpers/skills.py
+6
-1
@@ -431,6 +431,7 @@ def find_skill(
431
agent:Agent|None=None,
432
include_content: bool = False,
433
include_hidden: bool = False,
434
+ validate: bool = True,
435
) -> Optional[Skill]:
436
target = _normalize_name(skill_name)
437
if not target:
@@ -440,7 +441,11 @@ def find_skill(
441
442
for root in roots:
443
for skill_md in discover_skill_md_files(Path(root)):
443
- s = skill_from_markdown(skill_md, include_content=include_content)
444
+ s = skill_from_markdown(
445
+ skill_md,
446
+ include_content=include_content,
447
+ validate=validate,
448
+ )
449
if not s:
450
continue
451
if _normalize_name(s.name) == target or _normalize_name(s.path.name) == target:
helpers/skills.py.dox.md
+2
-1
@@ -28,7 +28,7 @@
28
- `skill_from_markdown(skill_md_path: Path, include_content: bool=..., validate: bool=...) -> Optional[Skill]`
29
- `list_skills(agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> List[Skill]`: List skills, optionally filtered by agent scope.
30
- `delete_skill(skill_path: str) -> None`: Delete a skill directory.
31
-- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> Optional[Skill]`
31
+- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=..., validate: bool=...) -> Optional[Skill]`
32
- `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context.
33
- `skill_instruction_name(message: Any) -> str`
34
- `_get_skill_files(skill_dir: Path) -> str`: Get file tree for skill directory.
@@ -59,6 +59,7 @@
59
- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
60
- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
61
- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions.
62
+- `find_skill(validate=False)` lets validation tooling resolve a skill with incomplete metadata while preserving runtime validation by default.
63
- Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly.
64
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
65
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
helpers/skills_cli.py
+27
-135
@@ -11,155 +11,47 @@ Usage:
11
"""
12
13
import argparse
14
-import os
14
import sys
16
-import yaml
17
-import re
15
from pathlib import Path
19
-from typing import Optional, List, Dict, Any
20
-from dataclasses import dataclass, field
21
-from datetime import datetime
16
23
-# Add parent directory to path for imports
24
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
17
+from helpers import files, skills as skills_runtime
18
26
-from helpers import files
19
20
+Skill = skills_runtime.Skill
21
29
-@dataclass
30
-class Skill:
31
- """Represents a skill loaded from SKILL.md"""
32
- name: str
33
- description: str
34
- path: Path
35
- version: str = "1.0.0"
36
- author: str = ""
37
- tags: List[str] = field(default_factory=list)
38
- trigger_patterns: List[str] = field(default_factory=list)
39
- content: str = ""
22
41
-
42
-def get_skills_dirs() -> List[Path]:
23
+def get_skills_dirs() -> list[Path]:
24
"""Get all skill directories"""
44
- base = Path(files.get_abs_path("usr", "skills"))
45
- roots = [
46
- Path(files.get_abs_path("skills")),
47
- base,
48
- base / "custom",
49
- base / "default",
50
- ]
51
- seen: set[Path] = set()
52
- ordered: List[Path] = []
53
- for root in roots:
54
- if root not in seen:
55
- seen.add(root)
56
- ordered.append(root)
57
- return ordered
58
-
59
-
60
-def parse_skill_file(skill_path: Path) -> Optional[Skill]:
25
+ return [Path(root) for root in skills_runtime.get_skill_roots()]
26
+
27
+
28
+def parse_skill_file(skill_path: Path) -> Skill | None:
29
"""Parse a SKILL.md file and return a Skill object"""
62
- try:
63
- content = skill_path.read_text(encoding="utf-8")
64
-
65
- # Parse YAML frontmatter
66
- if content.startswith("---"):
67
- parts = content.split("---", 2)
68
- if len(parts) >= 3:
69
- frontmatter = yaml.safe_load(parts[1])
70
- body = parts[2].strip()
71
-
72
- return Skill(
73
- name=frontmatter.get("name", skill_path.parent.name),
74
- description=frontmatter.get("description", ""),
75
- path=skill_path.parent,
76
- version=frontmatter.get("version", "1.0.0"),
77
- author=frontmatter.get("author", ""),
78
- tags=frontmatter.get("tags", []),
79
- trigger_patterns=frontmatter.get("trigger_patterns", []),
80
- content=body,
81
- )
82
-
83
- return None
84
- except Exception as e:
85
- print(f"Error parsing {skill_path}: {e}")
86
- return None
87
-
88
-
89
-def list_skills() -> List[Skill]:
90
- """List all available skills"""
91
- skills = []
92
- for skills_dir in get_skills_dirs():
93
- if not skills_dir.exists():
94
- continue
95
- for skill_dir in skills_dir.iterdir():
96
- if skill_dir.is_dir():
97
- skill_file = skill_dir / "SKILL.md"
98
- if skill_file.exists():
99
- skill = parse_skill_file(skill_file)
100
- if skill:
101
- skills.append(skill)
102
- return skills
103
-
104
-
105
-def find_skill(name: str) -> Optional[Skill]:
106
- """Find a skill by name"""
107
- for skill in list_skills():
108
- if skill.name == name or skill.path.name == name:
109
- return skill
110
- return None
30
+ return skills_runtime.skill_from_markdown(
31
+ skill_path,
32
+ include_content=True,
33
+ validate=False,
34
+ )
35
36
113
-def search_skills(query: str) -> List[Skill]:
114
- """Search skills by name, description, or tags"""
115
- query = query.lower()
116
- results = []
117
- for skill in list_skills():
118
- if (
119
- query in skill.name.lower()
120
- or query in skill.description.lower()
121
- or any(query in tag.lower() for tag in skill.tags)
122
- or any(query in trigger.lower() for trigger in skill.trigger_patterns)
123
- ):
124
- results.append(skill)
125
- return results
126
-
127
-
128
-def validate_skill(skill: Skill) -> List[str]:
129
- """Validate a skill and return list of issues"""
130
- issues = []
37
+def list_skills() -> list[Skill]:
38
+ """List all available skills"""
39
+ return skills_runtime.list_skills(include_content=True)
40
132
- # Required fields
133
- if not skill.name:
134
- issues.append("Missing required field: name")
135
- if not skill.description:
136
- issues.append("Missing required field: description")
41
138
- # Name format
139
- if skill.name:
140
- if not (1 <= len(skill.name) <= 64):
141
- issues.append("Name must be 1-64 characters")
142
- if not re.match(r"^[a-z0-9-]+$", skill.name):
143
- issues.append(f"Invalid name format: '{skill.name}' (use lowercase letters, numbers, and hyphens)")
144
- if skill.name.startswith("-") or skill.name.endswith("-"):
145
- issues.append("Name must not start or end with a hyphen")
146
- if "--" in skill.name:
147
- issues.append("Name must not contain consecutive hyphens")
42
+def find_skill(name: str) -> Skill | None:
43
+ """Find a skill by name"""
44
+ return skills_runtime.find_skill(name, include_content=True, validate=False)
45
149
- # Description length
150
- if skill.description and len(skill.description) < 20:
151
- issues.append("Description is too short (minimum 20 characters)")
46
153
- # Content
154
- if len(skill.content) < 100:
155
- issues.append("Skill content is too short (minimum 100 characters)")
47
+def search_skills(query: str) -> list[Skill]:
48
+ """Search skills by name, description, or tags"""
49
+ return skills_runtime.search_skills(query)
50
157
- # Check for associated files
158
- skill_dir = skill.path
159
- has_scripts = (skill_dir / "scripts").exists()
160
- has_docs = (skill_dir / "docs").exists()
51
162
- return issues
52
+def validate_skill(skill: Skill) -> list[str]:
53
+ """Validate a skill and return list of issues"""
54
+ return skills_runtime.validate_skill(skill)
55
56
57
def create_skill(name: str, description: str = "", author: str = "") -> Path:
@@ -184,7 +76,7 @@ description: "{description or 'Description of what this skill does and when to u
76
version: "1.0.0"
77
author: "{author or 'Your Name'}"
78
tags: ["custom"]
187
-trigger_patterns:
79
+triggers:
80
- "{name}"
81
---
82
@@ -234,7 +126,7 @@ Description of what to do next.
126
return skill_dir
127
128
237
-def print_skill_table(skills: List[Skill]):
129
+def print_skill_table(skills: list[Skill]):
130
"""Print skills in a formatted table"""
131
if not skills:
132
print("No skills found.")
@@ -330,7 +222,7 @@ Examples:
222
print(f"Author: {skill.author or 'Unknown'}")
223
print(f"Path: {skill.path}")
224
print(f"Tags: {', '.join(skill.tags) if skill.tags else 'None'}")
333
- print(f"Triggers: {', '.join(skill.trigger_patterns) if skill.trigger_patterns else 'None'}")
225
+ print(f"Triggers: {', '.join(skill.triggers) if skill.triggers else 'None'}")
226
print(f"\nDescription:")
227
print(f" {skill.description}")
228
print(f"\nContent Preview (first 500 chars):")
helpers/skills_cli.py.dox.md
+14
-12
@@ -10,29 +10,31 @@
10
11
- `skills_cli.py` owns the runtime implementation.
12
- `skills_cli.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
-- Classes:
14
-- `Skill` (no explicit base class)
13
+- Reuses `helpers.skills.Skill` rather than maintaining a CLI-specific model.
14
- Top-level functions:
16
-- `get_skills_dirs() -> List[Path]`: Get all skill directories
17
-- `parse_skill_file(skill_path: Path) -> Optional[Skill]`: Parse a SKILL.md file and return a Skill object
18
-- `list_skills() -> List[Skill]`: List all available skills
19
-- `find_skill(name: str) -> Optional[Skill]`: Find a skill by name
20
-- `search_skills(query: str) -> List[Skill]`: Search skills by name, description, or tags
21
-- `validate_skill(skill: Skill) -> List[str]`: Validate a skill and return list of issues
15
+- `get_skills_dirs() -> list[Path]`: Get all skill directories
16
+- `parse_skill_file(skill_path: Path) -> Skill | None`: Parse a SKILL.md file and return a Skill object
17
+- `list_skills() -> list[Skill]`: List all available skills
18
+- `find_skill(name: str) -> Skill | None`: Find a skill by name
19
+- `search_skills(query: str) -> list[Skill]`: Search skills by name, description, or tags
20
+- `validate_skill(skill: Skill) -> list[str]`: Validate a skill and return list of issues
21
- `create_skill(name: str, description: str=..., author: str=...) -> Path`: Create a new skill from template
23
-- `print_skill_table(skills: List[Skill])`: Print skills in a formatted table
22
+- `print_skill_table(skills: list[Skill])`: Print skills in a formatted table
23
- `main()`
24
25
## Runtime Contracts
26
27
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
28
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
29
+- Skill discovery, parsing, search, and validation delegate to `helpers.skills` so CLI behavior matches the runtime contract.
30
+- CLI lookup disables eager validation so the `validate` command can report incomplete metadata through the canonical validator.
31
+- Created skills use the canonical `triggers` frontmatter field.
32
- Observed side-effect areas: filesystem reads, filesystem writes, settings/state persistence, secret handling.
31
-- Imported dependency areas include: `argparse`, `dataclasses`, `datetime`, `helpers`, `os`, `pathlib`, `re`, `sys`, `typing`, `yaml`.
33
+- Imported dependency areas include: `argparse`, `helpers`, `pathlib`, `sys`.
34
35
## Key Concepts
36
35
-- Important called helpers/classes observed in the source: `sys.path.insert`, `field`, `Path`, `get_skills_dirs`, `list_skills`, `query.lower`, `exists`, `custom_dir.mkdir`, `skill_dir.exists`, `skill_dir.mkdir`, `mkdir`, `skill_file.write_text`, `readme.write_text`, `argparse.ArgumentParser`, `parser.add_subparsers`, `subparsers.add_parser`, `list_parser.add_argument`, `create_parser.add_argument`, `show_parser.add_argument`, `validate_parser.add_argument`.
37
+- Important called helpers/classes observed in the source: `skills.get_skill_roots`, `skills.skill_from_markdown`, `skills.list_skills`, `skills.find_skill`, `skills.search_skills`, `skills.validate_skill`, `Path`, `custom_dir.mkdir`, `skill_dir.exists`, `skill_dir.mkdir`, `skill_file.write_text`, `readme.write_text`, `argparse.ArgumentParser`, `parser.add_subparsers`, `subparsers.add_parser`.
38
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
39
40
## Work Guidance
@@ -44,7 +46,7 @@
46
## Verification
47
48
- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
47
-- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
49
+- `tests/test_skills_cli.py` verifies that creation, parsing, discovery, lookup, search, and validation use the runtime skill contract.
50
51
## Child DOX Index
52
tests/test_skills_cli.py
new
+38
@@ -0,0 +1,38 @@
1
+from helpers import skills, skills_cli
2
+
3
+
4
+def test_cli_uses_runtime_skill_contract(monkeypatch, tmp_path):
5
+ monkeypatch.setattr(
6
+ skills_cli.files,
7
+ "get_abs_path",
8
+ lambda *parts: str(tmp_path.joinpath(*parts)),
9
+ )
10
+ skill_dir = skills_cli.create_skill(
11
+ "cli-contract",
12
+ "Use this skill to verify the shared CLI and runtime contract.",
13
+ )
14
+ skills_root = tmp_path / "usr" / "skills"
15
+ monkeypatch.setattr(skills, "get_skill_roots", lambda agent=None: [str(skills_root)])
16
+
17
+ parsed = skills_cli.parse_skill_file(skill_dir / "SKILL.md")
18
+
19
+ assert isinstance(parsed, skills.Skill)
20
+ assert parsed.triggers == ["cli-contract"]
21
+ assert skills_cli.get_skills_dirs() == [skills_root]
22
+ assert [skill.name for skill in skills_cli.list_skills()] == ["cli-contract"]
23
+ assert skills_cli.find_skill("cli-contract").triggers == ["cli-contract"]
24
+ assert [skill.name for skill in skills_cli.search_skills("cli-contract")] == [
25
+ "cli-contract"
26
+ ]
27
+ assert skills_cli.validate_skill(parsed) == skills.validate_skill(parsed) == []
28
+
29
+ invalid_dir = skills_root / "invalid-skill"
30
+ invalid_dir.mkdir()
31
+ (invalid_dir / "SKILL.md").write_text(
32
+ "---\nname: invalid-skill\n---\n\n# Invalid skill\n",
33
+ encoding="utf-8",
34
+ )
35
+ invalid = skills_cli.find_skill("invalid-skill")
36
+
37
+ assert invalid is not None
38
+ assert skills_cli.validate_skill(invalid) == ["Missing required field: description"]