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.
Files changed (36) hide show
  1. kctl_skill-0.2.0/.gitignore +36 -0
  2. kctl_skill-0.2.0/PKG-INFO +8 -0
  3. kctl_skill-0.2.0/README.md +132 -0
  4. kctl_skill-0.2.0/pyproject.toml +40 -0
  5. kctl_skill-0.2.0/src/kctl_skill/__init__.py +5 -0
  6. kctl_skill-0.2.0/src/kctl_skill/cli.py +184 -0
  7. kctl_skill-0.2.0/src/kctl_skill/core/__init__.py +1 -0
  8. kctl_skill-0.2.0/src/kctl_skill/core/callbacks.py +10 -0
  9. kctl_skill-0.2.0/src/kctl_skill/core/config.py +19 -0
  10. kctl_skill-0.2.0/src/kctl_skill/core/exceptions.py +7 -0
  11. kctl_skill-0.2.0/src/kctl_skill/discovery.py +57 -0
  12. kctl_skill-0.2.0/src/kctl_skill/generate.py +153 -0
  13. kctl_skill-0.2.0/src/kctl_skill/install.py +96 -0
  14. kctl_skill-0.2.0/src/kctl_skill/lint.py +33 -0
  15. kctl_skill-0.2.0/src/kctl_skill/model.py +76 -0
  16. kctl_skill-0.2.0/src/kctl_skill/parser.py +96 -0
  17. kctl_skill-0.2.0/src/kctl_skill/py.typed +0 -0
  18. kctl_skill-0.2.0/src/kctl_skill/registry.py +27 -0
  19. kctl_skill-0.2.0/src/kctl_skill/rules/__init__.py +3 -0
  20. kctl_skill-0.2.0/src/kctl_skill/rules/budget.py +152 -0
  21. kctl_skill-0.2.0/src/kctl_skill/rules/integrity.py +281 -0
  22. kctl_skill-0.2.0/src/kctl_skill/rules/structure.py +196 -0
  23. kctl_skill-0.2.0/src/kctl_skill/scaffold.py +127 -0
  24. kctl_skill-0.2.0/tests/conftest.py +20 -0
  25. kctl_skill-0.2.0/tests/fixtures/fake_cli.py +29 -0
  26. kctl_skill-0.2.0/tests/test_cli_commands.py +96 -0
  27. kctl_skill-0.2.0/tests/test_discovery.py +79 -0
  28. kctl_skill-0.2.0/tests/test_generate.py +95 -0
  29. kctl_skill-0.2.0/tests/test_install.py +92 -0
  30. kctl_skill-0.2.0/tests/test_lint.py +87 -0
  31. kctl_skill-0.2.0/tests/test_parser.py +65 -0
  32. kctl_skill-0.2.0/tests/test_rules_budget.py +100 -0
  33. kctl_skill-0.2.0/tests/test_rules_integrity.py +138 -0
  34. kctl_skill-0.2.0/tests/test_rules_structure.py +116 -0
  35. kctl_skill-0.2.0/tests/test_scaffold.py +50 -0
  36. 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,5 @@
1
+ """kctl-skill: author, lint, build, and install Kodemeio agent skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
@@ -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
@@ -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