From fc7d238d0628adab6c373c819f6adda9ea918e21 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 17:38:43 +0100 Subject: [PATCH] feat(budget): count the skill listing budget with make budget scripts/budget.py holds the listing limits (1500 words in total, 40 per skill) and the one way to count a description, prints the total and the longest descriptions, reads the staged versions with --index, and exits 1 over budget. make budget [INDEX=1] runs it; test_lock.py takes its limits and counting from the script instead of its own copy. It replaces the one-liner that was run by hand a dozen times on 2026-11-03. Co-Authored-By: Claude Opus 5.5 --- Makefile | 6 +++- scripts/budget.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_lock.py | 32 ++++++++++++++------- 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100755 scripts/budget.py diff --git a/Makefile b/Makefile index 99adb1c..ae1689d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install project test update +.PHONY: install project test update budget # Install the skills globally into $HOME (.agents/skills + .claude/skills). install: @@ -19,3 +19,7 @@ test: # Update all skills to their latest versions and refresh skills-lock.json. update: @bash scripts/update.sh + +# Count skill description words against the listing budget: make budget [INDEX=1] +budget: + @python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index) diff --git a/scripts/budget.py b/scripts/budget.py new file mode 100755 index 0000000..e55f74f --- /dev/null +++ b/scripts/budget.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Count every first-party skill description against the listing budget. + +Args: + --repo: skills repository root. + --index: count the staged versions in the git index instead of the files. + --top: how many of the longest descriptions to list. + +Exit codes: + 0 within budget, 1 over budget, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +LISTING_WORDS = 1500 +DESCRIPTION_WORDS = 40 + + +def words(skill_md: str) -> int: + frontmatter = skill_md.split("---", 2)[1] + return len(frontmatter.split("description:", 1)[1].split()) - 1 + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + print(f"budget: {result.stderr.strip()}", file=sys.stderr) + sys.exit(2) + return result.stdout + + +def counts(repo: Path, index: bool) -> dict[str, int]: + if index: + paths = git(repo, "ls-files", "skills/*/SKILL.md").split() + return {Path(p).parent.name: words(git(repo, "show", f":{p}")) for p in paths} + return { + p.parent.name: words(p.read_text(encoding="utf-8")) + for p in sorted((repo / "skills").glob("*/SKILL.md")) + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", required=True) + parser.add_argument("--index", action="store_true") + parser.add_argument("--top", type=int, required=True) + args = parser.parse_args() + + repo = Path(args.repo) + if not (repo / "skills").is_dir(): + print(f"budget: {repo} has no skills/ directory", file=sys.stderr) + return 2 + found = counts(repo, args.index) + total = sum(found.values()) + over = sorted(name for name, n in found.items() if n > DESCRIPTION_WORDS) + print(f"total {total}/{LISTING_WORDS} words in {len(found)} skills") + for name, n in sorted(found.items(), key=lambda item: -item[1])[: args.top]: + print(f"{n}\t{name}") + for name in over: + print(f"over {DESCRIPTION_WORDS}: {name}") + return 1 if total > LISTING_WORDS or over else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_lock.py b/tests/test_lock.py index a3fe5e1..7ecf0a4 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -2,18 +2,23 @@ from __future__ import annotations +import importlib.util import json import re +import subprocess +import sys import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] LOCK_FILE = REPO_ROOT / "skills-lock.json" +BUDGET = REPO_ROOT / "scripts" / "budget.py" +SPEC = importlib.util.spec_from_file_location("budget", BUDGET) +budget = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(budget) HASH_RE = re.compile(r"^[0-9a-f]{64}$") REQUIRED_FIELDS = ("source", "sourceType", "skillPath", "computedHash") -DESCRIPTION_WORDS = 40 -LISTING_WORDS = 1500 class TestSkillsLock(unittest.TestCase): @@ -64,14 +69,21 @@ class TestSkillsLock(unittest.TestCase): ) def test_descriptions_fit_the_listing_budget(self): - total = 0 - for skill_md in sorted((REPO_ROOT / "skills").glob("*/SKILL.md")): - frontmatter = skill_md.read_text(encoding="utf-8").split("---", 2)[1] - words = len(frontmatter.split("description:", 1)[1].split()) - 1 - total += words - with self.subTest(skill=skill_md.parent.name): - self.assertLessEqual(words, DESCRIPTION_WORDS) - self.assertLessEqual(total, LISTING_WORDS) + found = budget.counts(REPO_ROOT, index=False) + for name, words in found.items(): + with self.subTest(skill=name): + self.assertLessEqual(words, budget.DESCRIPTION_WORDS) + self.assertLessEqual(sum(found.values()), budget.LISTING_WORDS) + + def test_budget_script_reports_the_total_and_the_longest(self): + result = subprocess.run( + [sys.executable, str(BUDGET), "--repo", str(REPO_ROOT), "--top", "3"], + capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertRegex(result.stdout, r"^total \d+/\d+ words in \d+ skills\n") + self.assertEqual(len(result.stdout.splitlines()), 4) + self.assertEqual(budget.words("---\nname: x\ndescription: >\n one two three\n---\n"), 3) def test_first_party_skills_do_not_shadow_locked_skills(self): locked = set(self.lock["skills"].keys())