kctl-skill 0.2.0__tar.gz
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-0.2.0/.gitignore +36 -0
- kctl_skill-0.2.0/PKG-INFO +8 -0
- kctl_skill-0.2.0/README.md +132 -0
- kctl_skill-0.2.0/pyproject.toml +40 -0
- kctl_skill-0.2.0/src/kctl_skill/__init__.py +5 -0
- kctl_skill-0.2.0/src/kctl_skill/cli.py +184 -0
- kctl_skill-0.2.0/src/kctl_skill/core/__init__.py +1 -0
- kctl_skill-0.2.0/src/kctl_skill/core/callbacks.py +10 -0
- kctl_skill-0.2.0/src/kctl_skill/core/config.py +19 -0
- kctl_skill-0.2.0/src/kctl_skill/core/exceptions.py +7 -0
- kctl_skill-0.2.0/src/kctl_skill/discovery.py +57 -0
- kctl_skill-0.2.0/src/kctl_skill/generate.py +153 -0
- kctl_skill-0.2.0/src/kctl_skill/install.py +96 -0
- kctl_skill-0.2.0/src/kctl_skill/lint.py +33 -0
- kctl_skill-0.2.0/src/kctl_skill/model.py +76 -0
- kctl_skill-0.2.0/src/kctl_skill/parser.py +96 -0
- kctl_skill-0.2.0/src/kctl_skill/py.typed +0 -0
- kctl_skill-0.2.0/src/kctl_skill/registry.py +27 -0
- kctl_skill-0.2.0/src/kctl_skill/rules/__init__.py +3 -0
- kctl_skill-0.2.0/src/kctl_skill/rules/budget.py +152 -0
- kctl_skill-0.2.0/src/kctl_skill/rules/integrity.py +281 -0
- kctl_skill-0.2.0/src/kctl_skill/rules/structure.py +196 -0
- kctl_skill-0.2.0/src/kctl_skill/scaffold.py +127 -0
- kctl_skill-0.2.0/tests/conftest.py +20 -0
- kctl_skill-0.2.0/tests/fixtures/fake_cli.py +29 -0
- kctl_skill-0.2.0/tests/test_cli_commands.py +96 -0
- kctl_skill-0.2.0/tests/test_discovery.py +79 -0
- kctl_skill-0.2.0/tests/test_generate.py +95 -0
- kctl_skill-0.2.0/tests/test_install.py +92 -0
- kctl_skill-0.2.0/tests/test_lint.py +87 -0
- kctl_skill-0.2.0/tests/test_parser.py +65 -0
- kctl_skill-0.2.0/tests/test_rules_budget.py +100 -0
- kctl_skill-0.2.0/tests/test_rules_integrity.py +138 -0
- kctl_skill-0.2.0/tests/test_rules_structure.py +116 -0
- kctl_skill-0.2.0/tests/test_scaffold.py +50 -0
- kctl_skill-0.2.0/tests/test_standard.py +41 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
|
|
14
|
+
# IDE
|
|
15
|
+
.idea/
|
|
16
|
+
.vscode/
|
|
17
|
+
*.swp
|
|
18
|
+
*.swo
|
|
19
|
+
|
|
20
|
+
# Testing
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.ruff_cache/
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Environment
|
|
32
|
+
.env
|
|
33
|
+
.env.local
|
|
34
|
+
|
|
35
|
+
# Agent memory (claude-mem regenerates AGENTS.md locally; not a committed guide)
|
|
36
|
+
AGENTS.md
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kctl-skill
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Toolchain for authoring, linting, building, and installing Kodemeio agent skills
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: kctl-lib>=0.14.0
|
|
7
|
+
Requires-Dist: rich>=13.0
|
|
8
|
+
Requires-Dist: typer>=0.9.0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# kctl-skill
|
|
2
|
+
|
|
3
|
+
Toolchain for authoring, linting, building, and installing **Kodemeio agent skills** —
|
|
4
|
+
the `skills/<name>/SKILL.md` bundles that Claude and other agents load to operate the
|
|
5
|
+
`kctl-*` CLI fleet. `kctl-skill` is the single generator and gate for all of them: it
|
|
6
|
+
replaces the 42 near-identical `commands/skill_cmd.py` files and the shared
|
|
7
|
+
`kctl_lib/skill_generator.py` module with one tool that knows what a skill is, so
|
|
8
|
+
individual CLIs no longer have to.
|
|
9
|
+
|
|
10
|
+
> **Status:** the toolchain is under active development. This package is currently
|
|
11
|
+
> scaffolding only — the commands documented below describe the intended surface and
|
|
12
|
+
> will land in subsequent tasks.
|
|
13
|
+
|
|
14
|
+
See the full design: `docs/standards/kctl-skill-architecture.md`.
|
|
15
|
+
|
|
16
|
+
## Install / run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync --all-extras --all-packages
|
|
20
|
+
uv run kctl-skill --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Like `kctl-conform`, this is a `kind = "meta"` developer tool — it has no profile
|
|
24
|
+
surface, no `--json`/`--profile` globals, and no external service to talk to. It reads
|
|
25
|
+
and writes the local filesystem (`skills/` at the repo root and the Typer app registry
|
|
26
|
+
of whichever CLI it is introspecting).
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
| Command | Purpose |
|
|
31
|
+
|---------|---------|
|
|
32
|
+
| `kctl-skill new <name> [--cli kctl-x]` | Scaffold a new skill from the template — profile rules, discovery, and safety sections pre-filled; domain-specific sections marked `TODO`. |
|
|
33
|
+
| `kctl-skill build [<name>...]` | Regenerate `references/*.md` from Typer introspection of the declared CLI. Never touches `SKILL.md`. |
|
|
34
|
+
| `kctl-skill lint [<name>...]` | Run the static `SKILL-*` rule family against one or all skills. |
|
|
35
|
+
| `kctl-skill eval [<name>...]` | Run behavioural evals — discovery and execution scenarios defined per skill. |
|
|
36
|
+
| `kctl-skill link [--clean] [--dry-run]` | Symlink-install skills into `~/.agents/skills/` (and `~/.claude/skills/` pointing at that). `--clean` removes stale copies; `--dry-run` previews without touching the filesystem. |
|
|
37
|
+
| `kctl-skill list` | Inventory of all skills with health status (drift, orphans, staleness). |
|
|
38
|
+
| `kctl-skill doctor` | Environment checks (skills root exists, CLIs resolvable, symlinks intact). |
|
|
39
|
+
|
|
40
|
+
### Example invocations
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Scaffold a new tier-1 tool skill for kctl-pg
|
|
44
|
+
uv run kctl-skill new pg-admin --cli kctl-pg
|
|
45
|
+
|
|
46
|
+
# Regenerate references/ for every skill after a CLI's commands changed
|
|
47
|
+
uv run kctl-skill build
|
|
48
|
+
|
|
49
|
+
# Lint just the odoo-admin skill
|
|
50
|
+
uv run kctl-skill lint odoo-admin
|
|
51
|
+
|
|
52
|
+
# Preview what `link` would install, without writing anything
|
|
53
|
+
uv run kctl-skill link --dry-run
|
|
54
|
+
|
|
55
|
+
# Symlink-install every skill for local development
|
|
56
|
+
uv run kctl-skill link
|
|
57
|
+
|
|
58
|
+
# Show every skill's health at a glance
|
|
59
|
+
uv run kctl-skill list
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## `skill.toml` — build configuration
|
|
63
|
+
|
|
64
|
+
Every skill carries a `skill.toml` sidecar. It is the toolchain's only control
|
|
65
|
+
surface: it declares the tier (which selects the applicable lint budget), the CLI to
|
|
66
|
+
introspect for tier-1 skills, and how command groups map onto generated reference
|
|
67
|
+
files.
|
|
68
|
+
|
|
69
|
+
```toml
|
|
70
|
+
tier = "tool" # "tool" (tier 1) | "domain" (tier 2)
|
|
71
|
+
cli = "kctl-odoo" # tier 1 only -- the console script to introspect
|
|
72
|
+
|
|
73
|
+
# Group clusters -> one generated reference file each.
|
|
74
|
+
# Every group in the CLI must appear in exactly one cluster; `kctl-skill build`
|
|
75
|
+
# errors on an unassigned or duplicated group so new commands cannot go missing.
|
|
76
|
+
[references]
|
|
77
|
+
"users-access" = ["users", "groups", "access-rights", "roles"]
|
|
78
|
+
"modules-database" = ["modules", "database", "backup", "addons"]
|
|
79
|
+
"inventory-manufacturing" = ["inventory", "stock", "mrp", "bom"]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For `tier = "domain"` skills the `cli` and `[references]` keys are omitted — their
|
|
83
|
+
references are hand-authored, and `kctl-skill build` skips them entirely.
|
|
84
|
+
|
|
85
|
+
## The `SKILL-*` rule family
|
|
86
|
+
|
|
87
|
+
`kctl-skill lint` implements these rules; `kctl-conform` imports and runs them so
|
|
88
|
+
there is one gate (`just conform`) and one baseline for the whole workspace.
|
|
89
|
+
|
|
90
|
+
| Rule | Sev | Checks |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| SKILL-001 | error | `SKILL.md` exists at `skills/<name>/SKILL.md` |
|
|
93
|
+
| SKILL-002 | error | Valid YAML frontmatter with `name` and `description` |
|
|
94
|
+
| SKILL-003 | error | `name` equals directory name, ≤64 chars, `[a-z0-9-]+` only, no reserved words |
|
|
95
|
+
| SKILL-004 | error | `description` non-empty, ≤1024 chars, no XML tags |
|
|
96
|
+
| SKILL-005 | error | `skill.toml` exists and parses; `tier` is `tool` or `domain`; `tool` tier declares a resolvable `cli` |
|
|
97
|
+
| SKILL-006 | error | For `tier = "tool"`: every command group of the declared CLI appears in exactly one `[references]` cluster |
|
|
98
|
+
| SKILL-010 | error | `SKILL.md` body ≤500 lines (spec ceiling) |
|
|
99
|
+
| SKILL-011 | warn | `SKILL.md` body ≤200 lines (`tier = "tool"`) or ≤300 lines (`tier = "domain"`) |
|
|
100
|
+
| SKILL-012 | warn | Reference file >100 lines has a `## Contents` table of contents |
|
|
101
|
+
| SKILL-020 | error | Description in third person — rejects leading `"I "`, `"You can"`, `"Use this to"` |
|
|
102
|
+
| SKILL-021 | warn | Description contains a *when to use* clause |
|
|
103
|
+
| SKILL-022 | error | Description is not the generated boilerplate |
|
|
104
|
+
| SKILL-030 | error | Every relative link in `SKILL.md` resolves to an existing file |
|
|
105
|
+
| SKILL-031 | error | References are one level deep — no linking onward to another skill-local `.md` |
|
|
106
|
+
| SKILL-032 | warn | Orphan detection — every file in `references/` is linked from `SKILL.md` |
|
|
107
|
+
| SKILL-040 | warn | No agent-specific constructs (`allowed-tools`, subagent references, `/slash-command`) |
|
|
108
|
+
| SKILL-041 | error | No Windows-style paths |
|
|
109
|
+
| SKILL-050 | error | Secret scan — high-entropy and known-prefix credential patterns |
|
|
110
|
+
| SKILL-051 | warn | No absolute `/home/<user>` paths |
|
|
111
|
+
| SKILL-060 | warn | Generated `references/` stale versus current CLI registry hash |
|
|
112
|
+
| SKILL-061 | warn | No unresolved `TODO`/`TBD` markers from the scaffold template |
|
|
113
|
+
| SKILL-070 | warn | Skill has ≥3 eval scenarios |
|
|
114
|
+
|
|
115
|
+
## Package classification
|
|
116
|
+
|
|
117
|
+
Registered in `kctl-conform` as `kind = "meta"`, like `kctl-conform` itself. Its own
|
|
118
|
+
`[tool.kctl-conform]` block in `pyproject.toml` exempts a small set of CLI rules that
|
|
119
|
+
are genuinely not applicable to a profile-less local authoring tool (no `--json`/
|
|
120
|
+
`--profile` globals, no `doctor ai-summary`, commands living directly in `cli.py`, no
|
|
121
|
+
`core/client.py`, and a trimmed `test_standard.py`).
|
|
122
|
+
|
|
123
|
+
## Tests
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv run pytest packages/kctl-skill/tests/ -v
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Further reading
|
|
130
|
+
|
|
131
|
+
- `docs/standards/kctl-skill-architecture.md` — the full standard this package
|
|
132
|
+
implements and enforces, including the rule rationale and migration notes.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kctl-skill"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Toolchain for authoring, linting, building, and installing Kodemeio agent skills"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = ["kctl-lib>=0.14.0", "typer>=0.9.0", "rich>=13.0"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
kctl-skill = "kctl_skill.cli:app"
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
kctl-lib = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/kctl_skill"]
|
|
20
|
+
|
|
21
|
+
[tool.kctl-conform]
|
|
22
|
+
kind = "meta"
|
|
23
|
+
# Rules genuinely N/A for a profile-less local authoring tool:
|
|
24
|
+
# AFFORD-001 = no global --json/--profile options
|
|
25
|
+
# AFFORD-004 = no `doctor ai-summary` (no external service to probe)
|
|
26
|
+
# LAYOUT-003 = commands live in cli.py; no commands/ package
|
|
27
|
+
# LAYOUT-006 = no core/client.py (reads the local filesystem, no API client)
|
|
28
|
+
# TEST-002 = test_standard.py has Version/Help only; no config/completions groups
|
|
29
|
+
# Task 11 DELETES the two rules below; drop these entries in that task.
|
|
30
|
+
# AFFORD-006 = no `skill generate` (generation centralises in this very package)
|
|
31
|
+
# LAYOUT-005 = no skills/<x>-admin/ (skills move to the repo root)
|
|
32
|
+
exempt = [
|
|
33
|
+
"AFFORD-001",
|
|
34
|
+
"AFFORD-004",
|
|
35
|
+
"AFFORD-006",
|
|
36
|
+
"LAYOUT-003",
|
|
37
|
+
"LAYOUT-005",
|
|
38
|
+
"LAYOUT-006",
|
|
39
|
+
"TEST-002",
|
|
40
|
+
]
|
|
@@ -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}")
|
|
@@ -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
|
|
@@ -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
|