kctl-skill 0.2.0__py3-none-any.whl

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.
kctl_skill/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """kctl-skill: author, lint, build, and install Kodemeio agent skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
kctl_skill/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ from dataclasses import asdict as _asdict
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from kctl_lib import cli_entrypoint, register_introspection_commands
10
+
11
+ from . import __version__
12
+ from .discovery import discover_skills, find_repo_root
13
+ from .generate import build_skill
14
+ from .install import KIND_LINK, KIND_OK, KIND_REMOVE_COPY, KIND_SKIP_FOREIGN, apply_links, plan_links
15
+ from .lint import has_errors, lint_all
16
+ from .model import TIER_DOMAIN, TIER_TOOL
17
+ from .scaffold import scaffold_skill
18
+
19
+ app = typer.Typer(
20
+ name="kctl-skill",
21
+ help="Author, lint, build, and install Kodemeio agent skills.",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ if value:
28
+ typer.echo(f"kctl-skill {__version__}")
29
+ raise typer.Exit()
30
+
31
+
32
+ @app.callback()
33
+ def main(
34
+ version: bool = typer.Option(
35
+ False,
36
+ "--version",
37
+ "-V",
38
+ callback=_version_callback,
39
+ is_eager=True,
40
+ help="Show version and exit.",
41
+ ),
42
+ ) -> None:
43
+ """kctl-skill CLI."""
44
+
45
+
46
+ @app.command("lint")
47
+ def lint_cmd(
48
+ names: Annotated[list[str] | None, typer.Argument(help="Skill names to lint. Omit for all.")] = None,
49
+ json_out: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
50
+ warnings_as_errors: Annotated[bool, typer.Option("--warnings-as-errors", help="Exit 1 on warnings too.")] = False,
51
+ ) -> None:
52
+ """Lint skills against the SKILL-* rule family."""
53
+ root = find_repo_root()
54
+ wanted = list(names) if names else None
55
+ findings = lint_all(root, names=wanted)
56
+
57
+ if json_out:
58
+ typer.echo(_json.dumps({"findings": [_asdict(f) for f in findings]}, indent=2))
59
+ else:
60
+ for f in findings:
61
+ typer.echo(f"{f.severity:<5} {f.rule_id} {f.location} — {f.detail}")
62
+ errors = sum(1 for f in findings if f.severity == "error")
63
+ warns = len(findings) - errors
64
+ count = len(discover_skills(root)) if wanted is None else len(wanted)
65
+ typer.echo(f"\n{errors} error(s), {warns} warning(s) across {count} skill(s)")
66
+
67
+ if has_errors(findings) or (warnings_as_errors and findings):
68
+ raise typer.Exit(code=1)
69
+
70
+
71
+ @app.command("new")
72
+ def new_cmd(
73
+ name: Annotated[str, typer.Argument(help="Skill name (lowercase, hyphens).")],
74
+ cli: Annotated[str, typer.Option("--cli", help="Console script to introspect. Implies tool tier.")] = "",
75
+ ) -> None:
76
+ """Scaffold a new skill directory."""
77
+ root = find_repo_root()
78
+ tier = TIER_TOOL if cli else TIER_DOMAIN
79
+ path = scaffold_skill(root, name, cli or None, tier)
80
+ typer.echo(f"created {path.relative_to(root).as_posix()} ({tier} tier)")
81
+ typer.echo("next: fill the TODO sections, then run `kctl-skill lint`")
82
+
83
+
84
+ @app.command("build")
85
+ def build_cmd(
86
+ names: Annotated[list[str] | None, typer.Argument(help="Skill names to build. Omit for all.")] = None,
87
+ ) -> None:
88
+ """Regenerate references/ for tool-tier skills from live CLI introspection."""
89
+ root = find_repo_root()
90
+ wanted = set(names) if names else None
91
+ total = 0
92
+ for ctx in discover_skills(root):
93
+ if wanted is not None and ctx.name not in wanted:
94
+ continue
95
+ written = build_skill(ctx)
96
+ if written:
97
+ typer.echo(f"{ctx.name}: {len(written)} reference file(s)")
98
+ total += len(written)
99
+ typer.echo(f"\n{total} reference file(s) written")
100
+
101
+
102
+ @app.command("list")
103
+ def list_cmd() -> None:
104
+ """List every skill with its tier, CLI binding, and reference count."""
105
+ root = find_repo_root()
106
+ skills = discover_skills(root)
107
+ for ctx in skills:
108
+ refs = len(list(ctx.references_dir().glob("*.md"))) if ctx.references_dir().is_dir() else 0
109
+ evals = len(list(ctx.evals_dir().glob("*.json"))) if ctx.evals_dir().is_dir() else 0
110
+ cli = ctx.config.cli or "-"
111
+ typer.echo(f"{ctx.name:<28} {ctx.config.tier:<7} {cli:<16} refs={refs:<3} evals={evals}")
112
+ typer.echo(f"\n{len(skills)} skill(s)")
113
+
114
+
115
+ @app.command("doctor")
116
+ def doctor_cmd(
117
+ home: Annotated[str, typer.Option("--home", help="Override the home directory (testing).")] = "",
118
+ ) -> None:
119
+ """Diagnose the skills tree and its installation: drift, staleness, and broken links."""
120
+ root = find_repo_root()
121
+ home_path = Path(home) if home else Path.home()
122
+ skills = discover_skills(root)
123
+
124
+ typer.echo(f"repo root: {root}")
125
+ typer.echo(f"skills: {len(skills)}")
126
+
127
+ tool = [c for c in skills if c.config.tier == TIER_TOOL]
128
+ typer.echo(f"tool tier: {len(tool)} domain tier: {len(skills) - len(tool)}")
129
+
130
+ findings = lint_all(root)
131
+ errors = sum(1 for f in findings if f.severity == "error")
132
+ stale = sum(1 for f in findings if f.rule_id == "SKILL-060")
133
+ typer.echo(f"lint: {errors} error(s), {len(findings) - errors} warning(s)")
134
+ typer.echo(f"stale refs: {stale} (run `kctl-skill build` to refresh)")
135
+
136
+ # clean=False makes this read-only: no action can remove anything.
137
+ actions = plan_links(root, home_path, clean=False)
138
+ linked = sum(1 for a in actions if a.kind == KIND_OK)
139
+ missing = sum(1 for a in actions if a.kind == KIND_LINK)
140
+ foreign = sum(1 for a in actions if a.kind == KIND_SKIP_FOREIGN)
141
+ typer.echo(f"install: {linked} linked, {missing} not linked, {foreign} blocked by a foreign path")
142
+ if foreign:
143
+ typer.echo(" run `kctl-skill link --clean` to replace stale copies")
144
+
145
+ if errors:
146
+ raise typer.Exit(code=1)
147
+
148
+
149
+ @app.command("link")
150
+ def link_cmd(
151
+ clean: Annotated[bool, typer.Option("--clean", help="Replace stale copied directories with symlinks.")] = False,
152
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Print the plan and exit without changes.")] = False,
153
+ yes: Annotated[bool, typer.Option("--yes", help="Skip the confirmation prompt for --clean removals.")] = False,
154
+ home: Annotated[str, typer.Option("--home", help="Override the home directory (testing).")] = "",
155
+ ) -> None:
156
+ """Symlink this repo's skills into ~/.agents/skills and ~/.claude/skills."""
157
+ root = find_repo_root()
158
+ home_path = Path(home) if home else Path.home()
159
+ actions = plan_links(root, home_path, clean=clean)
160
+
161
+ for action in actions:
162
+ typer.echo(f"{action.kind:<13} {action.path} — {action.reason}")
163
+
164
+ if dry_run:
165
+ typer.echo("\ndry run — nothing changed")
166
+ return
167
+
168
+ removals = [a for a in actions if a.kind == KIND_REMOVE_COPY]
169
+ if removals and not yes:
170
+ typer.echo(f"\n{len(removals)} directory/ies will be DELETED and replaced by symlinks:")
171
+ for a in removals:
172
+ typer.echo(f" {a.path}")
173
+ typer.confirm("Proceed?", abort=True)
174
+
175
+ changed = apply_links(actions)
176
+ typer.echo(f"\n{changed} change(s) applied")
177
+
178
+
179
+ # Later tasks append new commands/groups above this line.
180
+ # register_introspection_commands and cli_entrypoint must remain the last two
181
+ # statements in this module -- introspection needs every command registered
182
+ # first, and cli_entrypoint wraps the fully-assembled app for error handling.
183
+ register_introspection_commands(app)
184
+ app = cli_entrypoint(app)
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from kctl_lib import AppContextBase
6
+
7
+
8
+ @dataclass
9
+ class AppContext(AppContextBase):
10
+ """Typer context for kctl-skill. Inherits lazy Output init from the base."""
@@ -0,0 +1,19 @@
1
+ """Workspace root resolution for the skills toolchain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .exceptions import SkillError
8
+
9
+ _WORKSPACE_MARKER = "[tool.uv.workspace]"
10
+
11
+
12
+ def find_repo_root(start: Path | None = None) -> Path:
13
+ """Walk upward until a pyproject.toml declaring [tool.uv.workspace] is found."""
14
+ current = (start or Path.cwd()).resolve()
15
+ for candidate in (current, *current.parents):
16
+ pyproject = candidate / "pyproject.toml"
17
+ if pyproject.is_file() and _WORKSPACE_MARKER in pyproject.read_text(encoding="utf-8"):
18
+ return candidate
19
+ raise SkillError(f"no uv workspace root found from {current}")
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from kctl_lib import KctlError
4
+
5
+
6
+ class SkillError(KctlError):
7
+ """Raised when a skill directory or its configuration is unusable."""
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from .core.config import find_repo_root
7
+ from .core.exceptions import SkillError
8
+ from .model import TIER_DOMAIN, SkillConfig, SkillCtx
9
+
10
+ # Re-exported so callers can pull discovery + root resolution from one module.
11
+ __all__ = ["discover_skills", "find_repo_root", "load_skill_config"]
12
+
13
+
14
+ def load_skill_config(path: Path) -> SkillConfig:
15
+ """Parse a skill.toml. Raises SkillError with an actionable message on failure."""
16
+ if not path.is_file():
17
+ raise SkillError(f"skill.toml not found at {path}")
18
+ try:
19
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
20
+ except tomllib.TOMLDecodeError as exc:
21
+ raise SkillError(f"invalid TOML in {path}: {exc}") from exc
22
+ except OSError as exc:
23
+ raise SkillError(f"cannot read {path}: {exc}") from exc
24
+
25
+ raw_refs = data.get("references", {})
26
+ references: dict[str, list[str]] = {}
27
+ if isinstance(raw_refs, dict):
28
+ for cluster, groups in raw_refs.items():
29
+ if isinstance(groups, list):
30
+ references[str(cluster)] = [str(g) for g in groups]
31
+
32
+ cli = data.get("cli")
33
+ return SkillConfig(
34
+ tier=str(data.get("tier", TIER_DOMAIN)),
35
+ cli=str(cli) if cli else None,
36
+ references=references,
37
+ )
38
+
39
+
40
+ def discover_skills(root: Path) -> list[SkillCtx]:
41
+ """Return every skill under <root>/skills/, sorted by name.
42
+
43
+ A directory is a skill if it contains SKILL.md. A missing skill.toml is
44
+ treated as a domain-tier skill with no generated references, so a
45
+ hand-written skill works before the sidecar is added.
46
+ """
47
+ skills_dir = root / "skills"
48
+ if not skills_dir.is_dir():
49
+ return []
50
+ out: list[SkillCtx] = []
51
+ for d in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
52
+ if not (d / "SKILL.md").is_file():
53
+ continue
54
+ toml_path = d / "skill.toml"
55
+ config = load_skill_config(toml_path) if toml_path.is_file() else SkillConfig()
56
+ out.append(SkillCtx(name=d.name, path=d, config=config))
57
+ return out
kctl_skill/generate.py ADDED
@@ -0,0 +1,153 @@
1
+ """Generate a tool-tier skill's references/ from live Typer introspection.
2
+
3
+ This module never writes SKILL.md. Routers are hand-authored; only the command
4
+ surface is generated. That separation is what makes hand-written prose
5
+ structurally impossible to clobber on a rebuild.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import importlib
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from kctl_lib.introspection import dump_command_tree
17
+
18
+ from .core.exceptions import SkillError
19
+ from .model import TIER_TOOL, SkillCtx
20
+
21
+ HASH_MARKER = "registry_hash:"
22
+ _TOC_THRESHOLD = 100
23
+
24
+
25
+ def load_cli_app(cli_name: str) -> typer.Typer:
26
+ """Import a console-script name's Typer app: 'kctl-odoo' -> kctl_odoo.cli:app."""
27
+ module_name = f"{cli_name.replace('-', '_')}.cli"
28
+ try:
29
+ module = importlib.import_module(module_name)
30
+ except ImportError as exc:
31
+ raise SkillError(f"cannot import {module_name} for CLI {cli_name!r}: {exc}") from exc
32
+ app = getattr(module, "app", None)
33
+ if not isinstance(app, typer.Typer):
34
+ raise SkillError(f"{module_name} does not export a Typer `app` attribute")
35
+ return app
36
+
37
+
38
+ def command_tree_for_cli(cli_name: str) -> dict[str, Any]:
39
+ return dump_command_tree(load_cli_app(cli_name))
40
+
41
+
42
+ def group_names_for_cli(cli_name: str) -> list[str]:
43
+ """Top-level command group names, sorted. Root-level leaf commands are excluded."""
44
+ tree = command_tree_for_cli(cli_name)
45
+ return sorted(g["name"] for g in tree.get("groups", []))
46
+
47
+
48
+ def registry_hash(tree: dict[str, Any]) -> str:
49
+ """12-char digest over every group's name and leaf count — the staleness signal."""
50
+ parts: list[str] = []
51
+ for group in tree.get("groups", []):
52
+ leaves = len(group.get("commands", [])) + len(group.get("groups", []))
53
+ parts.append(f"{group['name']}:{leaves}")
54
+ raw = "|".join(sorted(parts))
55
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
56
+
57
+
58
+ def registry_hash_for_cli(cli_name: str) -> str:
59
+ return registry_hash(command_tree_for_cli(cli_name))
60
+
61
+
62
+ def _flag_summary(params: list[dict[str, Any]]) -> str:
63
+ """Render a compact `<arg> [--opt]` signature from a command's param schema."""
64
+ parts: list[str] = []
65
+ for p in params:
66
+ if p.get("param_type") == "argument":
67
+ parts.append(f"<{p['name']}>")
68
+ else:
69
+ opt = next((o for o in p.get("opts", []) if o.startswith("--")), None)
70
+ if opt:
71
+ parts.append(opt if p.get("required") else f"[{opt}]")
72
+ return " ".join(parts)
73
+
74
+
75
+ def _first_sentence(text: str) -> str:
76
+ first = text.strip().split("\n\n")[0].replace("\n", " ").strip()
77
+ for marker in ("Examples:", "Example:"):
78
+ if marker in first:
79
+ first = first[: first.index(marker)].strip()
80
+ return first
81
+
82
+
83
+ def _render_group(cli_name: str, group: dict[str, Any], lines: list[str]) -> None:
84
+ lines.append(f"### `{cli_name} {group['name']}`")
85
+ lines.append("")
86
+ if group.get("help"):
87
+ lines.append(_first_sentence(group["help"]))
88
+ lines.append("")
89
+ commands = group.get("commands", [])
90
+ if commands:
91
+ lines.append("| Command | Description |")
92
+ lines.append("|---------|-------------|")
93
+ for cmd in commands:
94
+ sig = _flag_summary(cmd.get("params", []))
95
+ path = f"{group['name']} {cmd['name']}" + (f" {sig}" if sig else "")
96
+ lines.append(f"| `{path}` | {_first_sentence(cmd.get('help', ''))} |")
97
+ lines.append("")
98
+ for sub in group.get("groups", []):
99
+ _render_group(cli_name, {**sub, "name": f"{group['name']} {sub['name']}"}, lines)
100
+
101
+
102
+ def render_reference(cli_name: str, cluster: str, groups: list[dict[str, Any]], hash_: str) -> str:
103
+ """Render one reference file for a cluster of command groups."""
104
+ body: list[str] = []
105
+ for group in groups:
106
+ _render_group(cli_name, group, body)
107
+
108
+ header = [
109
+ f"<!-- generated by `kctl-skill build` — do not edit. {HASH_MARKER} {hash_} -->",
110
+ "",
111
+ f"# {cli_name} — {cluster}",
112
+ "",
113
+ f"Discover exact flags before invoking: `{cli_name} commands tree --filter <group> --json`",
114
+ "",
115
+ ]
116
+
117
+ toc = ["## Contents", ""]
118
+ toc.extend(f"- `{cli_name} {g['name']}` — {_first_sentence(g.get('help', '')) or 'commands'}" for g in groups)
119
+ toc.append("")
120
+
121
+ # SKILL-012 measures the FINAL file, so the threshold has to be applied to
122
+ # the rendered length including header and ToC — not to `body` alone.
123
+ if len(header) + len(toc) + len(body) > _TOC_THRESHOLD:
124
+ header.extend(toc)
125
+
126
+ return "\n".join(header + body).rstrip() + "\n"
127
+
128
+
129
+ def build_skill(ctx: SkillCtx) -> list[Path]:
130
+ """Regenerate every references/<cluster>.md for a tool-tier skill.
131
+
132
+ Domain-tier skills own their references by hand and are skipped.
133
+ Returns the list of files written.
134
+ """
135
+ if ctx.config.tier != TIER_TOOL or not ctx.config.cli:
136
+ return []
137
+
138
+ tree = command_tree_for_cli(ctx.config.cli)
139
+ hash_ = registry_hash(tree)
140
+ by_name = {g["name"]: g for g in tree.get("groups", [])}
141
+
142
+ refs_dir = ctx.references_dir()
143
+ refs_dir.mkdir(parents=True, exist_ok=True)
144
+
145
+ written: list[Path] = []
146
+ for cluster, group_names in sorted(ctx.config.references.items()):
147
+ groups = [by_name[n] for n in group_names if n in by_name]
148
+ if not groups:
149
+ continue
150
+ target = refs_dir / f"{cluster}.md"
151
+ target.write_text(render_reference(ctx.config.cli, cluster, groups, hash_), encoding="utf-8")
152
+ written.append(target)
153
+ return written
kctl_skill/install.py ADDED
@@ -0,0 +1,96 @@
1
+ """Symlink this repo's skills into the agent skill directories.
2
+
3
+ Symlinks rather than copies: the link IS the repo file, so the three-way drift
4
+ that copy-install produced becomes structurally impossible.
5
+
6
+ Safety contract — this module deletes directories, so the rules are narrow:
7
+
8
+ 1. Only paths whose basename exactly matches a discovered skill name are ever
9
+ considered. Unrelated skills in the destination are never even inspected.
10
+ 2. A real directory is removed only under ``clean``.
11
+ 3. A symlink already pointing into this repo is ours, and is relinked freely.
12
+ 4. A symlink pointing anywhere else is never touched — reported as skip-foreign.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .discovery import discover_skills
22
+
23
+ KIND_LINK = "link"
24
+ KIND_RELINK = "relink"
25
+ KIND_REMOVE_COPY = "remove-copy"
26
+ KIND_SKIP_FOREIGN = "skip-foreign"
27
+ KIND_OK = "ok"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LinkAction:
32
+ kind: str
33
+ path: Path
34
+ target: Path | None
35
+ reason: str
36
+
37
+
38
+ def _points_into(link: Path, root: Path) -> bool:
39
+ """True when `link` is a symlink resolving inside `root`."""
40
+ if not link.is_symlink():
41
+ return False
42
+ try:
43
+ resolved = link.resolve()
44
+ except OSError:
45
+ return False
46
+ root_resolved = root.resolve()
47
+ return resolved == root_resolved or root_resolved in resolved.parents
48
+
49
+
50
+ def _plan_one(dest: Path, source: Path, root: Path, clean: bool) -> LinkAction:
51
+ """Decide what to do with a single destination path.
52
+
53
+ Never widens beyond an exact name match — the caller has already established
54
+ that `dest.name` is one of our skills.
55
+ """
56
+ if dest.is_symlink():
57
+ if _points_into(dest, root):
58
+ if dest.resolve() == source.resolve():
59
+ return LinkAction(KIND_OK, dest, source, "already linked")
60
+ return LinkAction(KIND_RELINK, dest, source, "symlink points at a different repo skill")
61
+ return LinkAction(KIND_SKIP_FOREIGN, dest, None, "symlink points outside the repo — left untouched")
62
+
63
+ if dest.exists():
64
+ if clean:
65
+ return LinkAction(KIND_REMOVE_COPY, dest, source, "stale copied directory replaced by a symlink")
66
+ return LinkAction(KIND_SKIP_FOREIGN, dest, None, "real directory present — rerun with --clean to replace")
67
+
68
+ return LinkAction(KIND_LINK, dest, source, "new link")
69
+
70
+
71
+ def plan_links(root: Path, home: Path, clean: bool) -> list[LinkAction]:
72
+ """Compute every filesystem action needed to link this repo's skills into `home`."""
73
+ agents_dir = home / ".agents" / "skills"
74
+ claude_dir = home / ".claude" / "skills"
75
+ actions: list[LinkAction] = []
76
+ for ctx in discover_skills(root):
77
+ source = ctx.path
78
+ actions.append(_plan_one(agents_dir / ctx.name, source, root, clean))
79
+ actions.append(_plan_one(claude_dir / ctx.name, source, root, clean))
80
+ return actions
81
+
82
+
83
+ def apply_links(actions: list[LinkAction]) -> int:
84
+ """Execute a plan. Returns the number of filesystem changes made."""
85
+ changed = 0
86
+ for action in actions:
87
+ if action.kind in (KIND_OK, KIND_SKIP_FOREIGN) or action.target is None:
88
+ continue
89
+ action.path.parent.mkdir(parents=True, exist_ok=True)
90
+ if action.kind == KIND_REMOVE_COPY:
91
+ shutil.rmtree(action.path)
92
+ elif action.kind == KIND_RELINK:
93
+ action.path.unlink()
94
+ action.path.symlink_to(action.target, target_is_directory=True)
95
+ changed += 1
96
+ return changed
kctl_skill/lint.py ADDED
@@ -0,0 +1,33 @@
1
+ """Run the SKILL-* rule family over one skill or the whole tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import rules as _rules # noqa: F401 (importing registers every rule)
8
+ from .discovery import discover_skills
9
+ from .model import SEVERITY_ERROR, Finding, SkillCtx
10
+ from .parser import parse_skill_md
11
+ from .registry import RULES
12
+
13
+
14
+ def lint_skill(ctx: SkillCtx) -> list[Finding]:
15
+ parsed = parse_skill_md(ctx.skill_md())
16
+ out: list[Finding] = []
17
+ for _rule, check in RULES:
18
+ out.extend(check(ctx, parsed))
19
+ return out
20
+
21
+
22
+ def lint_all(root: Path, names: list[str] | None = None) -> list[Finding]:
23
+ wanted = set(names) if names else None
24
+ findings: list[Finding] = []
25
+ for ctx in discover_skills(root):
26
+ if wanted is not None and ctx.name not in wanted:
27
+ continue
28
+ findings.extend(lint_skill(ctx))
29
+ return sorted(findings, key=lambda f: (f.location, f.rule_id))
30
+
31
+
32
+ def has_errors(findings: list[Finding]) -> bool:
33
+ return any(f.severity == SEVERITY_ERROR for f in findings)
kctl_skill/model.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ TIER_TOOL = "tool"
7
+ TIER_DOMAIN = "domain"
8
+ VALID_TIERS = (TIER_TOOL, TIER_DOMAIN)
9
+
10
+ SEVERITY_ERROR = "error"
11
+ SEVERITY_WARN = "warn"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SkillConfig:
16
+ """Parsed skill.toml. The toolchain's only control surface for a skill."""
17
+
18
+ tier: str = TIER_DOMAIN
19
+ cli: str | None = None
20
+ references: dict[str, list[str]] = field(default_factory=dict)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SkillCtx:
25
+ name: str
26
+ path: Path
27
+ config: SkillConfig
28
+
29
+ def skill_md(self) -> Path:
30
+ return self.path / "SKILL.md"
31
+
32
+ def skill_toml(self) -> Path:
33
+ return self.path / "skill.toml"
34
+
35
+ def references_dir(self) -> Path:
36
+ return self.path / "references"
37
+
38
+ def evals_dir(self) -> Path:
39
+ return self.path / "evals"
40
+
41
+ def rel(self, root: Path) -> str:
42
+ return self.path.relative_to(root).as_posix()
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Frontmatter:
47
+ name: str
48
+ description: str
49
+ extra: dict[str, object] = field(default_factory=dict)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ParsedSkill:
54
+ frontmatter: Frontmatter | None
55
+ body: str
56
+ body_lines: int
57
+ error: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Finding:
62
+ rule_id: str
63
+ location: str
64
+ detail: str
65
+ severity: str
66
+
67
+ def key(self) -> str:
68
+ return f"{self.rule_id}::{self.location}"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class SkillRule:
73
+ id: str
74
+ severity: str
75
+ title: str
76
+ rationale: str