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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 17:57:08 +01:00
1 parent 66e21dcd5c
commit fc7d238d06
3 files changed
+99 -11

No files matched your search

+5 -1
View File
@@ -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)
+72
View File
@@ -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())
+22 -10
View File
@@ -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())