diff --git a/converter/generate_commands.py b/converter/generate_commands.py index 82f0fb8..5458d10 100644 --- a/converter/generate_commands.py +++ b/converter/generate_commands.py @@ -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:`) as a separate skill entry in /context, with +# its own description-derived token cost — independent of the SKILL.md +# `description:` that drives the bare `/` 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)}") @@ -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 @@ -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: @@ -114,13 +96,12 @@ 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(): @@ -128,9 +109,8 @@ def write_commands(dist: Path, skills: list[tuple[str, str, str | None]]) -> lis 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) diff --git a/tests/test_generate_commands.py b/tests/test_generate_commands.py index 962d20a..e4b5693 100644 --- a/tests/test_generate_commands.py +++ b/tests/test_generate_commands.py @@ -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 @@ -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 @@ -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] == "---" @@ -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 -------- @@ -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):