Skip to content

Commit 63a2a17

Browse files
jawwad-aliclaude
andauthored
fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
Extension-provided commands that declare `argument-hint:` in their frontmatter had that field dropped from the generated Claude `.claude/skills/<name>/SKILL.md`, while core template commands keep it. The extension skill generator built the frontmatter via the shared build_skill_frontmatter() (name/description/compatibility/metadata only) and never forwarded argument-hint. Carry argument-hint from the parsed source command frontmatter into the skill frontmatter dict before serialization, gated on the integration exposing inject_argument_hint so only argument-hint-aware agents (Claude) receive the key and build_skill_frontmatter's shared shape stays unchanged for every other agent. The value is injected into the dict rather than via the string-based inject_argument_hint helper, so a folded multi-line description cannot be split into invalid YAML. Add regression tests covering a folding description (Claude) and the non-Claude gate (kimi). Closes #2903 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36ad3cd commit 63a2a17

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,22 @@ def _register_extension_skills(
10531053
description,
10541054
f"extension:{manifest.id}",
10551055
)
1056+
# Preserve the command's argument-hint in the generated skill,
1057+
# mirroring the core template path (ClaudeIntegration.setup injects
1058+
# it for built-in commands). The value is added to the frontmatter
1059+
# dict before serialization — rather than via the string-based
1060+
# inject_argument_hint helper — so that a folded multi-line
1061+
# description cannot be split by the inserted line. Gated on the
1062+
# integration exposing inject_argument_hint so only argument-hint
1063+
# aware agents receive the key, leaving build_skill_frontmatter's
1064+
# shared shape unchanged for every other agent.
1065+
argument_hint = frontmatter.get("argument-hint")
1066+
if (
1067+
argument_hint
1068+
and integration is not None
1069+
and hasattr(integration, "inject_argument_hint")
1070+
):
1071+
frontmatter_data["argument-hint"] = str(argument_hint)
10561072
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
10571073

10581074
# Derive a human-friendly title from the command name

tests/test_extension_skills.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,135 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
303303
assert "description" in parsed
304304
assert parsed["disable-model-invocation"] is False
305305

306+
def test_argument_hint_preserved_for_extension_command(
307+
self, skills_project, temp_dir
308+
):
309+
"""argument-hint from an extension command must survive into SKILL.md.
310+
311+
Regression for #2903: the field was dropped for extension-provided
312+
commands while being kept for core template commands. The source
313+
description is intentionally long so it folds across multiple lines
314+
when serialized, guarding against an in-place string injection that
315+
would split the folded scalar and produce invalid YAML.
316+
"""
317+
project_dir, skills_dir = skills_project
318+
319+
long_description = (
320+
"Build and maintain a lean, static context/ knowledge folder so "
321+
"coding agents load only what is relevant and save tokens"
322+
)
323+
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
324+
325+
ext_dir = temp_dir / "hint-ext"
326+
ext_dir.mkdir()
327+
manifest_data = {
328+
"schema_version": "1.0",
329+
"extension": {
330+
"id": "hint-ext",
331+
"name": "Hint Extension",
332+
"version": "1.0.0",
333+
"description": "Extension exercising argument-hint preservation",
334+
},
335+
"requires": {"speckit_version": ">=0.1.0"},
336+
"provides": {
337+
"commands": [
338+
{
339+
"name": "speckit.hint-ext.build-context",
340+
"file": "commands/build-context.md",
341+
"description": long_description,
342+
}
343+
]
344+
},
345+
}
346+
with open(ext_dir / "extension.yml", "w") as f:
347+
yaml.dump(manifest_data, f)
348+
commands_dir = ext_dir / "commands"
349+
commands_dir.mkdir()
350+
(commands_dir / "build-context.md").write_text(
351+
"---\n"
352+
f'description: "{long_description}"\n'
353+
f'argument-hint: "{arg_hint}"\n'
354+
"---\n"
355+
"\n"
356+
"# Build Context\n"
357+
"\n"
358+
"Do the thing.\n"
359+
"$ARGUMENTS\n",
360+
encoding="utf-8",
361+
)
362+
363+
manager = ExtensionManager(project_dir)
364+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
365+
366+
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
367+
assert skill_file.exists()
368+
content = skill_file.read_text(encoding="utf-8")
369+
370+
# Frontmatter must parse cleanly even though the description folds.
371+
parts = content.split("---", 2)
372+
assert len(parts) >= 3
373+
parsed = yaml.safe_load(parts[1])
374+
assert parsed["argument-hint"] == arg_hint
375+
assert parsed["description"] == long_description
376+
377+
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
378+
"""argument-hint must stay Claude-only — other skills agents are untouched.
379+
380+
The hint is carried only for integrations that support it (currently
381+
Claude, the sole integration defining inject_argument_hint). A non-Claude
382+
skills agent such as kimi must keep the shared build_skill_frontmatter
383+
shape (name/description/compatibility/metadata) with no argument-hint.
384+
"""
385+
_create_init_options(project_dir, ai="kimi", ai_skills=True)
386+
skills_dir = _create_skills_dir(project_dir, ai="kimi")
387+
388+
arg_hint = "<init | update | list | check> [area]"
389+
ext_dir = temp_dir / "hint-ext-kimi"
390+
ext_dir.mkdir()
391+
manifest_data = {
392+
"schema_version": "1.0",
393+
"extension": {
394+
"id": "hint-ext-kimi",
395+
"name": "Hint Extension Kimi",
396+
"version": "1.0.0",
397+
"description": "Extension exercising argument-hint gating",
398+
},
399+
"requires": {"speckit_version": ">=0.1.0"},
400+
"provides": {
401+
"commands": [
402+
{
403+
"name": "speckit.hint-ext-kimi.build-context",
404+
"file": "commands/build-context.md",
405+
"description": "Build context",
406+
}
407+
]
408+
},
409+
}
410+
with open(ext_dir / "extension.yml", "w") as f:
411+
yaml.dump(manifest_data, f)
412+
commands_dir = ext_dir / "commands"
413+
commands_dir.mkdir()
414+
(commands_dir / "build-context.md").write_text(
415+
"---\n"
416+
'description: "Build context"\n'
417+
f'argument-hint: "{arg_hint}"\n'
418+
"---\n"
419+
"\n"
420+
"# Build Context\n"
421+
"\n"
422+
"Do the thing.\n"
423+
"$ARGUMENTS\n",
424+
encoding="utf-8",
425+
)
426+
427+
manager = ExtensionManager(project_dir)
428+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
429+
430+
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
431+
assert skill_file.exists()
432+
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
433+
assert "argument-hint" not in parsed
434+
306435
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
307436
"""No skills should be created when ai_skills is false."""
308437
manager = ExtensionManager(no_skills_project)

0 commit comments

Comments
 (0)