Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 17 additions & 37 deletions converter/generate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,38 +37,20 @@
"Regenerate via converter/generate_commands.py. -->"
)

# Hard cap on description length for slash-command autocomplete display.
# Skill descriptions are often multi-sentence trigger blurbs; the autocomplete
# row truncates anyway, but a shorter explicit cap keeps the rendered list
# scannable and avoids passing huge strings through the harness.
MAX_DESCRIPTION_LEN = 140
# Command-wrapper `description:` is the skill name itself (not the SKILL.md
# description blurb). Rationale: Claude Code counts each plugin-namespaced
# command (`/ce-lite:<name>`) as a separate skill entry in /context, with
# its own description-derived token cost — independent of the SKILL.md
# `description:` that drives the bare `/<name>` entry. Carrying the full
# blurb in both places double-charged ~6k tokens of idle context for ce-lite.
# The skill name alone is just enough to satisfy validate.py's non-empty
# check while collapsing the duplicate to ~5 tokens per command.


def shorten_description(description: str) -> str:
"""Reduce a skill description to a single autocomplete-friendly line.

Strategy: take the first sentence (up to first '. ' boundary), then hard-
truncate to MAX_DESCRIPTION_LEN with an ellipsis if still too long. Skill
descriptions are often paragraphs of trigger phrasing — the command file
only needs the action-oriented headline.
"""
text = " ".join(description.split()) # collapse internal whitespace
if not text:
return ""
# First-sentence split. Look for ". " (period + space), not bare "." which
# would split on abbreviations like "etc." or version numbers.
period_idx = text.find(". ")
if 0 < period_idx < MAX_DESCRIPTION_LEN:
text = text[: period_idx + 1]
if len(text) > MAX_DESCRIPTION_LEN:
text = text[: MAX_DESCRIPTION_LEN - 3].rstrip() + "..."
return text


def render_command(skill_name: str, description: str, argument_hint: str | None) -> str:
def render_command(skill_name: str, argument_hint: str | None) -> str:
"""Render the command-file body for one skill."""
fm_lines = [
f"description: {json.dumps(description)}",
f"description: {json.dumps(skill_name)}",
]
if argument_hint:
fm_lines.append(f"argument-hint: {json.dumps(argument_hint)}")
Expand All @@ -87,8 +69,8 @@ def render_command(skill_name: str, description: str, argument_hint: str | None)
"""


def collect_skills(dist: Path) -> list[tuple[str, str, str | None]]:
"""Return [(skill_name, description, argument_hint?), ...] for every SKILL.md.
def collect_skills(dist: Path) -> list[tuple[str, str | None]]:
"""Return [(skill_name, argument_hint?), ...] for every SKILL.md.

Fails loud if any SKILL.md has malformed frontmatter or no `name:` — the
same skills will be expected to exist by validate.py, so silent skips
Expand All @@ -98,7 +80,7 @@ def collect_skills(dist: Path) -> list[tuple[str, str, str | None]]:
if not skills_dir.is_dir():
raise FileNotFoundError(f"missing {skills_dir}")

out: list[tuple[str, str, str | None]] = []
out: list[tuple[str, str | None]] = []
for skill_md in sorted(skills_dir.glob("*/SKILL.md")):
text = skill_md.read_text(encoding="utf-8")
try:
Expand All @@ -114,23 +96,21 @@ def collect_skills(dist: Path) -> list[tuple[str, str, str | None]]:
f"{skill_md}: skill dir {skill_md.parent.name!r} does not match "
f"frontmatter name {name!r}"
)
description = fm.get("description") or ""
argument_hint = fm.get("argument-hint") or None
out.append((name, description, argument_hint))
out.append((name, argument_hint))
return out


def write_commands(dist: Path, skills: list[tuple[str, str, str | None]]) -> list[Path]:
def write_commands(dist: Path, skills: list[tuple[str, str | None]]) -> list[Path]:
commands_dir = dist / "commands"
# Always start from a clean dir so renames in dist/skills/ propagate cleanly.
if commands_dir.exists():
shutil.rmtree(commands_dir)
commands_dir.mkdir(parents=True)

written: list[Path] = []
for name, description, argument_hint in skills:
short = shorten_description(description)
body = render_command(name, short, argument_hint)
for name, argument_hint in skills:
body = render_command(name, argument_hint)
out_path = commands_dir / f"{name}.md"
out_path.write_text(body, encoding="utf-8")
written.append(out_path)
Expand Down
67 changes: 14 additions & 53 deletions tests/test_generate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

Covers the pieces that would silently produce wrong output:

- shorten_description: first-sentence cut, hard truncate, empty input
- render_command: frontmatter shape with/without argument-hint, body format
- collect_skills: fail-loud on missing name or dir/name mismatch
- write_commands: idempotent clean-and-rewrite of dist/commands/, one file per skill
Expand All @@ -19,59 +18,20 @@
sys.path.insert(0, str(CONVERTER_DIR))

from generate_commands import ( # noqa: E402
MAX_DESCRIPTION_LEN,
collect_skills,
render_command,
shorten_description,
write_commands,
)


# -------- shorten_description --------


def test_shorten_description_empty():
assert shorten_description("") == ""


def test_shorten_description_short_passthrough():
text = "Execute work efficiently while maintaining quality."
assert shorten_description(text) == text


def test_shorten_description_first_sentence_cut():
text = "Create a plan. Then iterate on it. Then commit."
# First sentence ends at index 13 (". "), well under MAX_DESCRIPTION_LEN
assert shorten_description(text) == "Create a plan."


def test_shorten_description_no_period_hard_truncate():
text = "a" * 200
out = shorten_description(text)
assert len(out) == MAX_DESCRIPTION_LEN
assert out.endswith("...")


def test_shorten_description_long_first_sentence_falls_through_to_truncate():
# First sentence longer than MAX_DESCRIPTION_LEN — the period-cut shouldn't
# fire (it only fires when period is within the cap). Truncate kicks in.
text = "x" * 150 + ". another sentence."
out = shorten_description(text)
assert len(out) == MAX_DESCRIPTION_LEN
assert out.endswith("...")


def test_shorten_description_collapses_whitespace():
text = "Plan tasks\n with structure."
assert shorten_description(text) == "Plan tasks with structure."


# -------- render_command --------


def test_render_command_minimal_no_argument_hint():
body = render_command("ce-plan", "Plan tasks with structure.", None)
assert 'description: "Plan tasks with structure."' in body
body = render_command("ce-plan", None)
# Description is the skill name itself — see generate_commands.py comment
# for why we deliberately don't carry the SKILL.md description blurb here.
assert 'description: "ce-plan"' in body
assert "argument-hint" not in body
assert "Use the `ce-plan` skill" in body
assert "$ARGUMENTS" in body
Expand All @@ -81,16 +41,15 @@ def test_render_command_minimal_no_argument_hint():
def test_render_command_with_argument_hint():
body = render_command(
"ce-work",
"Execute work.",
"[Plan doc path or description of work]",
)
assert 'description: "Execute work."' in body
assert 'description: "ce-work"' in body
assert 'argument-hint: "[Plan doc path or description of work]"' in body
assert "Use the `ce-work` skill" in body


def test_render_command_frontmatter_well_formed():
body = render_command("ce-x", "desc", "hint")
body = render_command("ce-x", "hint")
# Frontmatter fence shape: starts with --- on its own line, then keys, then ---
lines = body.split("\n")
assert lines[0] == "---"
Expand All @@ -99,10 +58,12 @@ def test_render_command_frontmatter_well_formed():
assert lines[3] == "---"


def test_render_command_handles_quotes_in_description():
# JSON encoding handles embedded quotes safely
body = render_command("ce-x", 'Run "the thing" carefully.', None)
assert 'description: "Run \\"the thing\\" carefully."' in body
def test_render_command_description_is_always_the_skill_name():
# Even if a caller passes an argument-hint with quoted content, the
# description field stays pinned to the skill name (JSON-quoted).
body = render_command("ce-foo", 'with "quoted" hint')
assert 'description: "ce-foo"' in body
assert 'argument-hint: "with \\"quoted\\" hint"' in body


# -------- collect_skills --------
Expand Down Expand Up @@ -132,8 +93,8 @@ def test_collect_skills_basic(tmp_path: Path):

# ce-plan has an argument-hint, ce-work doesn't
by_name = {s[0]: s for s in skills}
assert by_name["ce-plan"][2] == "[args]"
assert by_name["ce-work"][2] is None
assert by_name["ce-plan"][1] == "[args]"
assert by_name["ce-work"][1] is None


def test_collect_skills_fails_on_missing_skills_dir(tmp_path: Path):
Expand Down