Skip to content

Commit 6644f69

Browse files
fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
* fix(ai-skills): exclude non-speckit copilot agent markdown from skill generation * Potential fix for pull request finding Fix missing `.agent` filename suffix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix test assertion speckit.plan.md to speckit.plan.agent Fix test assertion speckit.plan.md to speckit.plan.agent * Fix filter glob based on review suggestions fix(ai-skills): normalize Copilot .agent template names and align template fallback filtering * Add template glob for fallback directory * GH Copilot Suggestions Clarify comment regarding Copilot's use of templates in tests. Add extra test assertion * fix(ai-skills): normalize Copilot .agent templates and preserve fallback behavior fix(ai-skills): handle Copilot .agent templates and fallback filtering Normalize Copilot command template names by stripping the .agent suffix when deriving skill names and metadata sources, so files like speckit.plan.agent.md produce speckit-plan and map to plan.md metadata. Also align Copilot template discovery with speckit.* filtering while preserving fallback to templates/commands/ when .github/agents contains only user-authored markdown files, and add regression coverage for both non-speckit agent exclusion and fallback behavior. * fix(ai-skills): ignore non-speckit markdown commands --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a177a1a commit 6644f69

File tree

2 files changed

+124
-15
lines changed

2 files changed

+124
-15
lines changed

src/specify_cli/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,23 +1256,29 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
12561256
else:
12571257
templates_dir = project_path / commands_subdir
12581258

1259-
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
1259+
# Only consider speckit.*.md templates so that user-authored command
1260+
# files (e.g. custom slash commands, agent files) coexisting in the
1261+
# same commands directory are not incorrectly converted into skills.
1262+
template_glob = "speckit.*.md"
1263+
1264+
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
12601265
# Fallback: try the repo-relative path (for running from source checkout)
12611266
# This also covers agents whose extracted commands are in a different
12621267
# format (e.g. gemini/tabnine use .toml, not .md).
12631268
script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/
12641269
fallback_dir = script_dir / "templates" / "commands"
12651270
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
12661271
templates_dir = fallback_dir
1272+
template_glob = "*.md"
12671273

1268-
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
1274+
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
12691275
if tracker:
12701276
tracker.error("ai-skills", "command templates not found")
12711277
else:
12721278
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
12731279
return False
12741280

1275-
command_files = sorted(templates_dir.glob("*.md"))
1281+
command_files = sorted(templates_dir.glob(template_glob))
12761282
if not command_files:
12771283
if tracker:
12781284
tracker.skip("ai-skills", "no command templates found")
@@ -1311,11 +1317,14 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
13111317
body = content
13121318

13131319
command_name = command_file.stem
1314-
# Normalize: extracted commands may be named "speckit.<cmd>.md";
1315-
# strip the "speckit." prefix so skill names stay clean and
1320+
# Normalize: extracted commands may be named "speckit.<cmd>.md"
1321+
# or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
1322+
# any trailing ".agent" suffix so skill names stay clean and
13161323
# SKILL_DESCRIPTIONS lookups work.
13171324
if command_name.startswith("speckit."):
13181325
command_name = command_name[len("speckit."):]
1326+
if command_name.endswith(".agent"):
1327+
command_name = command_name[:-len(".agent")]
13191328
# Kimi CLI discovers skills by directory name and invokes them as
13201329
# /skill:<name> — use dot separator to match packaging convention.
13211330
if selected_ai == "kimi":
@@ -1340,6 +1349,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
13401349
source_name = command_file.name
13411350
if source_name.startswith("speckit."):
13421351
source_name = source_name[len("speckit."):]
1352+
if source_name.endswith(".agent.md"):
1353+
source_name = source_name[:-len(".agent.md")] + ".md"
13431354

13441355
frontmatter_data = {
13451356
"name": skill_name,

tests/test_ai_skills.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def templates_dir(project_dir):
6262
tpl_root.mkdir(parents=True, exist_ok=True)
6363

6464
# Template with valid YAML frontmatter
65-
(tpl_root / "specify.md").write_text(
65+
(tpl_root / "speckit.specify.md").write_text(
6666
"---\n"
6767
"description: Create or update the feature specification.\n"
6868
"handoffs:\n"
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
7979
)
8080

8181
# Template with minimal frontmatter
82-
(tpl_root / "plan.md").write_text(
82+
(tpl_root / "speckit.plan.md").write_text(
8383
"---\n"
8484
"description: Generate implementation plan.\n"
8585
"---\n"
@@ -91,15 +91,15 @@ def templates_dir(project_dir):
9191
)
9292

9393
# Template with no frontmatter
94-
(tpl_root / "tasks.md").write_text(
94+
(tpl_root / "speckit.tasks.md").write_text(
9595
"# Tasks Command\n"
9696
"\n"
9797
"Body without frontmatter.\n",
9898
encoding="utf-8",
9999
)
100100

101101
# Template with empty YAML frontmatter (yaml.safe_load returns None)
102-
(tpl_root / "empty_fm.md").write_text(
102+
(tpl_root / "speckit.empty_fm.md").write_text(
103103
"---\n"
104104
"---\n"
105105
"\n"
@@ -337,7 +337,7 @@ def test_malformed_yaml_frontmatter(self, project_dir):
337337
cmds_dir = project_dir / ".claude" / "commands"
338338
cmds_dir.mkdir(parents=True)
339339

340-
(cmds_dir / "broken.md").write_text(
340+
(cmds_dir / "speckit.broken.md").write_text(
341341
"---\n"
342342
"description: [unclosed bracket\n"
343343
" invalid: yaml: content: here\n"
@@ -430,9 +430,12 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
430430

431431
# Place .md templates in the agent's commands directory
432432
agent_folder = AGENT_CONFIG[agent_key]["folder"]
433-
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
433+
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
434+
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
434435
cmds_dir.mkdir(parents=True)
435-
(cmds_dir / "specify.md").write_text(
436+
# Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md
437+
fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md"
438+
(cmds_dir / fname).write_text(
436439
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
437440
)
438441

@@ -448,7 +451,100 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
448451
assert expected_skill_name in skill_dirs
449452
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
450453

454+
def test_copilot_ignores_non_speckit_agents(self, project_dir):
455+
"""Non-speckit markdown in .github/agents/ must not produce skills."""
456+
agents_dir = project_dir / ".github" / "agents"
457+
agents_dir.mkdir(parents=True, exist_ok=True)
458+
(agents_dir / "speckit.plan.agent.md").write_text(
459+
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
460+
)
461+
(agents_dir / "my-custom-agent.agent.md").write_text(
462+
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
463+
)
451464

465+
result = install_ai_skills(project_dir, "copilot")
466+
467+
assert result is True
468+
skills_dir = _get_skills_dir(project_dir, "copilot")
469+
assert skills_dir.exists()
470+
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
471+
assert "speckit-plan" in skill_dirs
472+
assert "speckit-my-custom-agent.agent" not in skill_dirs
473+
assert "speckit-my-custom-agent" not in skill_dirs
474+
475+
@pytest.mark.parametrize("agent_key,custom_file", [
476+
("claude", "review.md"),
477+
("cursor-agent", "deploy.md"),
478+
("qwen", "my-workflow.md"),
479+
])
480+
def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):
481+
"""User-authored command files must not produce skills for any agent."""
482+
proj = temp_dir / f"proj-{agent_key}"
483+
proj.mkdir()
484+
485+
agent_folder = AGENT_CONFIG[agent_key]["folder"]
486+
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
487+
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
488+
cmds_dir.mkdir(parents=True)
489+
(cmds_dir / "speckit.specify.md").write_text(
490+
"---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n"
491+
)
492+
(cmds_dir / custom_file).write_text(
493+
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
494+
)
495+
496+
result = install_ai_skills(proj, agent_key)
497+
498+
assert result is True
499+
skills_dir = _get_skills_dir(proj, agent_key)
500+
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
501+
assert "speckit-specify" in skill_dirs
502+
custom_stem = Path(custom_file).stem
503+
assert f"speckit-{custom_stem}" not in skill_dirs
504+
505+
def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):
506+
"""Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files."""
507+
agents_dir = project_dir / ".github" / "agents"
508+
agents_dir.mkdir(parents=True, exist_ok=True)
509+
# Only a user-authored agent, no speckit.* templates
510+
(agents_dir / "my-custom-agent.agent.md").write_text(
511+
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
512+
)
513+
514+
result = install_ai_skills(project_dir, "copilot")
515+
516+
# Should succeed via fallback to templates/commands/
517+
assert result is True
518+
skills_dir = _get_skills_dir(project_dir, "copilot")
519+
assert skills_dir.exists()
520+
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
521+
# Should have skills from fallback templates, not from the custom agent
522+
assert "speckit-plan" in skill_dirs
523+
assert not any("my-custom" in d for d in skill_dirs)
524+
525+
@pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"])
526+
def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):
527+
"""Fallback to templates/commands/ when agent dir has no speckit.*.md files."""
528+
proj = temp_dir / f"proj-{agent_key}"
529+
proj.mkdir()
530+
531+
agent_folder = AGENT_CONFIG[agent_key]["folder"]
532+
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
533+
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
534+
cmds_dir.mkdir(parents=True)
535+
# Only a user-authored command, no speckit.* templates
536+
(cmds_dir / "my-custom-command.md").write_text(
537+
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
538+
)
539+
540+
result = install_ai_skills(proj, agent_key)
541+
542+
# Should succeed via fallback to templates/commands/
543+
assert result is True
544+
skills_dir = _get_skills_dir(proj, agent_key)
545+
assert skills_dir.exists()
546+
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
547+
assert not any("my-custom" in d for d in skill_dirs)
452548

453549
class TestCommandCoexistence:
454550
"""Verify install_ai_skills never touches command files.
@@ -460,14 +556,16 @@ class TestCommandCoexistence:
460556

461557
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
462558
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
463-
# Verify commands exist before
464-
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
559+
# Verify commands exist before (templates_dir adds 4 speckit.* files,
560+
# commands_dir_claude overlaps with 3 of them)
561+
before = list(commands_dir_claude.glob("speckit.*"))
562+
assert len(before) >= 3
465563

466564
install_ai_skills(project_dir, "claude")
467565

468566
# Commands must still be there — install_ai_skills never touches them
469567
remaining = list(commands_dir_claude.glob("speckit.*"))
470-
assert len(remaining) == 3
568+
assert len(remaining) == len(before)
471569

472570
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
473571
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""

0 commit comments

Comments
 (0)