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 +5 -0
- kctl_skill/cli.py +184 -0
- kctl_skill/core/__init__.py +1 -0
- kctl_skill/core/callbacks.py +10 -0
- kctl_skill/core/config.py +19 -0
- kctl_skill/core/exceptions.py +7 -0
- kctl_skill/discovery.py +57 -0
- kctl_skill/generate.py +153 -0
- kctl_skill/install.py +96 -0
- kctl_skill/lint.py +33 -0
- kctl_skill/model.py +76 -0
- kctl_skill/parser.py +96 -0
- kctl_skill/py.typed +0 -0
- kctl_skill/registry.py +27 -0
- kctl_skill/rules/__init__.py +3 -0
- kctl_skill/rules/budget.py +152 -0
- kctl_skill/rules/integrity.py +281 -0
- kctl_skill/rules/structure.py +196 -0
- kctl_skill/scaffold.py +127 -0
- kctl_skill-0.2.0.dist-info/METADATA +8 -0
- kctl_skill-0.2.0.dist-info/RECORD +23 -0
- kctl_skill-0.2.0.dist-info/WHEEL +4 -0
- kctl_skill-0.2.0.dist-info/entry_points.txt +2 -0
kctl_skill/__init__.py
ADDED
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,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}")
|
kctl_skill/discovery.py
ADDED
|
@@ -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
|