From 06a0ec58d720d41a55025998bb9514752ffc8f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A5=E5=AF=85?= Date: Mon, 7 Sep 2026 10:08:02 +0800 Subject: [PATCH 1/4] fix: preserve extension authors in generated skills Carry extension author metadata through skill registration, aliases, and restoration after preset removal. Keep the existing default for core skills and extensions without an author. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) --- src/specify_cli/agents.py | 13 +++++++++++- src/specify_cli/extensions/__init__.py | 3 +++ src/specify_cli/presets/__init__.py | 2 ++ tests/test_extension_skills.py | 29 ++++++++++++++++++++++++++ tests/test_presets.py | 3 +++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index dede50e0b1..eb944a40fa 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -178,6 +178,7 @@ def _adjust_script_paths( Args: frontmatter: Frontmatter dictionary extension_id: Extension id when rendering extension-owned commands. + author: Author attributed in generated skill metadata. Returns: Modified frontmatter with normalized project paths @@ -402,6 +403,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. @@ -432,6 +434,7 @@ def render_skill_command( skill_name, description, f"{source_id}:{source_file}", + author=author, ) return self.render_frontmatter(skill_frontmatter) + "\n" + body @@ -441,6 +444,7 @@ 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 = { @@ -448,7 +452,7 @@ def build_skill_frontmatter( "description": description, "compatibility": "Requires spec-kit project structure with .specify/ directory", "metadata": { - "author": "github-spec-kit", + "author": author, "source": source, }, } @@ -618,6 +622,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. @@ -636,6 +641,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 +808,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 +895,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 +929,7 @@ def register_commands( cmd_file, project_root, extension_id=extension_id, + author=author, ) alias_file = ( @@ -1060,6 +1069,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. @@ -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 diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a440b6da9b..a17c1868a9 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") or "github-spec-kit", ) # 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") or "github-spec-kit", ) 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") or "github-spec-kit", ) def unregister_commands( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index abc63299c2..23ae77e7ad 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") or "github-spec-kit", "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..49e30a85f2 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -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 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 ): From 635bc2b0922f9cb0dd7f2beb232c9171339e22a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A5=E5=AF=85?= Date: Mon, 7 Sep 2026 10:09:13 +0800 Subject: [PATCH 2/4] test: accept author in extension registrar stub Assisted-by: OpenAI Codex (model: GPT-6, autonomous) --- tests/test_extensions.py | 1 + 1 file changed, 1 insertion(+) 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 From 1e6a1d1bb0b2ae6aded97ff68fdde95dbd5a662d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A5=E5=AF=85?= Date: Mon, 7 Sep 2026 10:11:57 +0800 Subject: [PATCH 3/4] docs: place author parameter description on registrar Assisted-by: OpenAI Codex (model: GPT-6, autonomous) --- src/specify_cli/agents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index eb944a40fa..ecaa517ccb 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -178,7 +178,6 @@ def _adjust_script_paths( Args: frontmatter: Frontmatter dictionary extension_id: Extension id when rendering extension-owned commands. - author: Author attributed in generated skill metadata. Returns: Modified frontmatter with normalized project paths @@ -1087,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). From af464fe80bfc4169b4e67e9ee2b7e2cdee87b80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A5=E5=AF=85?= Date: Wed, 9 Sep 2026 09:02:56 +0800 Subject: [PATCH 4/4] fix: normalize extension skill authors Assisted-by: OpenAI Codex (model: GPT-5, autonomous) --- src/specify_cli/agents.py | 13 ++++++++----- src/specify_cli/extensions/__init__.py | 6 +++--- src/specify_cli/presets/__init__.py | 2 +- tests/test_extension_skills.py | 23 ++++++++++++++++++++--- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ecaa517ccb..b5f018b89a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -402,7 +402,7 @@ def render_skill_command( source_file: str, project_root: Path, extension_id: Optional[str] = None, - author: str = "github-spec-kit", + author: object = "github-spec-kit", ) -> str: """Render a command override as a SKILL.md file. @@ -443,15 +443,18 @@ def build_skill_frontmatter( skill_name: str, description: str, source: str, - author: str = "github-spec-kit", + 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": author, + "author": normalized_author, "source": source, }, } @@ -621,7 +624,7 @@ def register_commands( _resolved_dir: Optional[Path] = None, link_outputs: bool = False, extension_id: Optional[str] = None, - author: str = "github-spec-kit", + author: object = "github-spec-kit", ) -> List[str]: """Register commands for a specific agent. @@ -1068,7 +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: str = "github-spec-kit", + author: object = "github-spec-kit", ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a17c1868a9..cb78f0ea88 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1687,7 +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", + author=manifest.data["extension"].get("author"), ) # Preserve the command's argument-hint in the generated skill, # mirroring the core template path (ClaudeIntegration.setup injects @@ -3616,7 +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", + author=manifest.data["extension"].get("author"), ) def register_commands_for_all_agents( @@ -3640,7 +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", + 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 23ae77e7ad..2c6470be52 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2894,7 +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", + "author": manifest.data["extension"].get("author"), "extension_id": manifest.id, "extension_dir": ext_root, } diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 49e30a85f2..3622fd994e 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -447,9 +447,26 @@ def test_skill_md_content_correct(self, skills_project, extension_dir): @pytest.mark.parametrize("register_commands", [False, True]) @pytest.mark.parametrize("link_commands", [False, True]) - @pytest.mark.parametrize("author", ["acme-corp", 'Acme: "Platform"\nTeam', None, ""]) + @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 + 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 @@ -471,7 +488,7 @@ def test_extension_author_preserved( 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 frontmatter["metadata"]["author"] == expected_author assert "test-ext" in frontmatter["metadata"]["source"] def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):