diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index dede50e0b1..b5f018b89a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -402,6 +402,7 @@ def render_skill_command( source_file: str, project_root: Path, extension_id: Optional[str] = None, + author: object = "github-spec-kit", ) -> str: """Render a command override as a SKILL.md file. @@ -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 @@ -441,14 +443,18 @@ def build_skill_frontmatter( skill_name: str, description: str, source: str, + author: object = "github-spec-kit", ) -> dict: """Build consistent SKILL.md frontmatter across all skill generators.""" + normalized_author = ( + "github-spec-kit" if author is None or author == "" else str(author) + ) skill_frontmatter = { "name": skill_name, "description": description, "compatibility": "Requires spec-kit project structure with .specify/ directory", "metadata": { - "author": "github-spec-kit", + "author": normalized_author, "source": source, }, } @@ -618,6 +624,7 @@ def register_commands( _resolved_dir: Optional[Path] = None, link_outputs: bool = False, extension_id: Optional[str] = None, + author: object = "github-spec-kit", ) -> List[str]: """Register commands for a specific agent. @@ -636,6 +643,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 @@ -802,6 +810,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) elif agent_config["format"] == "markdown": body = self.resolve_skill_placeholders( @@ -888,6 +897,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( @@ -921,6 +931,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) alias_file = ( @@ -1060,6 +1071,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: object = "github-spec-kit", ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -1077,6 +1089,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). @@ -1184,6 +1197,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 diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a440b6da9b..cb78f0ea88 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1687,6 +1687,7 @@ def _replacement(match: re.Match[str]) -> str: skill_name, description, f"extension:{manifest.id}", + author=manifest.data["extension"].get("author"), ) # Preserve the command's argument-hint in the generated skill, # mirroring the core template path (ClaudeIntegration.setup injects @@ -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"), ) def register_commands_for_all_agents( @@ -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"), ) def unregister_commands( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index abc63299c2..2c6470be52 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -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"), "extension_id": manifest.id, "extension_dir": ext_root, } @@ -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) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index a0a32f4d29..3622fd994e 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -445,6 +445,52 @@ 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", "expected_author"), + [ + ("acme-corp", "acme-corp"), + ('Acme: "Platform"\nTeam', 'Acme: "Platform"\nTeam'), + (None, "github-spec-kit"), + ("", "github-spec-kit"), + (123, "123"), + (0, "0"), + (False, "False"), + ], + ) + def test_extension_author_preserved( + self, + skills_project, + extension_dir, + register_commands, + link_commands, + author, + expected_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"] == expected_author + 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 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index aec32dc4ba..b38554cb3c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -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 diff --git a/tests/test_presets.py b/tests/test_presets.py index 57a70b4192..33e74d2c2d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -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", }, @@ -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 ):