Skip to content
Open
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
13 changes: 12 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ def render_skill_command(
source_file: str,
project_root: Path,
extension_id: Optional[str] = None,
author: str = "github-spec-kit",
) -> str:
"""Render a command override as a SKILL.md file.

Expand Down Expand Up @@ -432,6 +433,7 @@ def render_skill_command(
skill_name,
description,
f"{source_id}:{source_file}",
author=author,
)
return self.render_frontmatter(skill_frontmatter) + "\n" + body

Expand All @@ -441,14 +443,15 @@ def build_skill_frontmatter(
skill_name: str,
description: str,
source: str,
author: str = "github-spec-kit",
) -> dict:
"""Build consistent SKILL.md frontmatter across all skill generators."""
skill_frontmatter = {
"name": skill_name,
"description": description,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"author": author,
"source": source,
},
}
Expand Down Expand Up @@ -618,6 +621,7 @@ def register_commands(
_resolved_dir: Optional[Path] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
author: str = "github-spec-kit",
) -> List[str]:
"""Register commands for a specific agent.

Expand All @@ -636,6 +640,7 @@ def register_commands(
dev cache and symlink the agent command file to it. Falls back
to a normal file write when symlinks are unavailable.
extension_id: Extension id when rendering extension-owned commands.
author: Author attributed in generated skill metadata.

Returns:
List of registered command names
Expand Down Expand Up @@ -802,6 +807,7 @@ def register_commands(
cmd_file,
project_root,
extension_id=extension_id,
author=author,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(
Expand Down Expand Up @@ -888,6 +894,7 @@ def register_commands(
cmd_file,
project_root,
extension_id=extension_id,
author=author,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
Expand Down Expand Up @@ -921,6 +928,7 @@ def register_commands(
cmd_file,
project_root,
extension_id=extension_id,
author=author,
)

alias_file = (
Expand Down Expand Up @@ -1060,6 +1068,7 @@ def register_commands_for_all_agents(
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
only_agent: Optional[str] = None,
author: str = "github-spec-kit",
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.

Expand All @@ -1077,6 +1086,7 @@ def register_commands_for_all_agents(
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
author: Author attributed in generated skill metadata.
only_agent: If set, restrict registration to this single agent
while keeping all detection and recovery safeguards (#2948).

Expand Down Expand Up @@ -1184,6 +1194,7 @@ def register_commands_for_all_agents(
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
author=author,
)
if registered:
results[agent_name] = registered
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,7 @@ def _replacement(match: re.Match[str]) -> str:
skill_name,
description,
f"extension:{manifest.id}",
author=manifest.data["extension"].get("author") or "github-spec-kit",
)
# Preserve the command's argument-hint in the generated skill,
# mirroring the core template path (ClaudeIntegration.setup injects
Expand Down Expand Up @@ -3615,6 +3616,7 @@ def register_commands_for_agent(
context_note=context_note,
link_outputs=link_outputs,
extension_id=manifest.id,
author=manifest.data["extension"].get("author") or "github-spec-kit",
)

def register_commands_for_all_agents(
Expand All @@ -3638,6 +3640,7 @@ def register_commands_for_all_agents(
create_missing_active_skills_dir=create_missing_active_skills_dir,
only_agent=only_agent,
extension_id=manifest.id,
author=manifest.data["extension"].get("author") or "github-spec-kit",
)

def unregister_commands(
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2894,6 +2894,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"command_name": cmd_name,
"source_file": source_file,
"source": f"extension:{manifest.id}",
"author": manifest.data["extension"].get("author") or "github-spec-kit",
"extension_id": manifest.id,
"extension_dir": ext_root,
}
Expand Down Expand Up @@ -3805,6 +3806,7 @@ def _unregister_skills_in_dir(
skill_name,
frontmatter.get("description", f"Extension command: {command_name}"),
extension_restore["source"],
author=extension_restore.get("author", "github-spec-kit"),
)
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
frontmatter_text = dump_frontmatter(frontmatter_data)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_extension_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,35 @@ def test_skill_md_content_correct(self, skills_project, extension_dir):
assert "compatibility:" in content
assert "Run this to say hello." in content

@pytest.mark.parametrize("register_commands", [False, True])
@pytest.mark.parametrize("link_commands", [False, True])
@pytest.mark.parametrize("author", ["acme-corp", 'Acme: "Platform"\nTeam', None, ""])
def test_extension_author_preserved(
self, skills_project, extension_dir, register_commands, link_commands, author
):
"""Both skill generators retain attribution, including dev output and aliases."""
project_dir, skills_dir = skills_project
manifest_path = extension_dir / "extension.yml"
data = yaml.safe_load(manifest_path.read_text())
if author is not None:
data["extension"]["author"] = author
data["provides"]["commands"][0]["aliases"] = ["speckit.test-ext.greet"]
manifest_path.write_text(yaml.safe_dump(data))

ExtensionManager(project_dir).install_from_directory(
extension_dir, "0.1.0",
register_commands=register_commands, link_commands=link_commands,
)

names = ["hello", "world"]
if register_commands:
names.append("greet")
for name in names:
content = (skills_dir / f"speckit-test-ext-{name}" / "SKILL.md").read_text()
frontmatter = yaml.safe_load(content.split("---", 2)[1])
assert frontmatter["metadata"]["author"] == (author or "github-spec-kit")
assert "test-ext" in frontmatter["metadata"]["source"]

def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
project_dir, skills_dir = skills_project
Expand Down
1 change: 1 addition & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1746,6 +1746,7 @@ def fake_register_all(
create_missing_active_skills_dir=False,
extension_id=None,
only_agent=None,
author="github-spec-kit",
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
Expand Down
3 changes: 3 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6328,6 +6328,7 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"author": "acme-corp",
"version": "1.0.0",
"description": "Test",
},
Expand Down Expand Up @@ -6394,6 +6395,8 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
assert "Read agents/control" not in content
assert "# Fakeext Cmd Skill" in content

assert yaml.safe_load(content.split("---", 2)[1])["metadata"]["author"] == "acme-corp"

def test_skill_composed_over_extension_base_rewrites_subdir_paths(
self, project_dir, temp_dir
):
Expand Down