diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index f8ea47ccb2..e5c9f9e2de 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -2,7 +2,9 @@ Requires ``--commands-dir`` to specify the output directory for command files. No longer special-cased in the core CLI — just another -integration with its own required option. +integration with its own required option. ``--skills`` renders the same +templates as ``speckit-/SKILL.md`` directories under that same +directory instead of flat ``speckit..md`` files. """ from __future__ import annotations @@ -10,10 +12,25 @@ from pathlib import Path from typing import Any -from ..base import IntegrationOption, MarkdownIntegration +import yaml + +from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration, yaml_quote from ..manifest import IntegrationManifest +class _GenericSkillsHelper(SkillsIntegration): + """Internal helper supplying skills-mode post-processing for + ``GenericIntegration`` (e.g. the dot-to-hyphen hook invocation note). + + Not registered in the integration registry — ``GenericIntegration`` + itself renders skills content directly in ``_build_skill_content()`` + and only delegates to this helper's ``post_process_skill_content()``, + mirroring the pattern ``CopilotIntegration`` uses for its skills mode. + """ + + key = "generic" + + class GenericIntegration(MarkdownIntegration): """Integration for user-specified (generic) agents.""" @@ -40,6 +57,16 @@ def options(cls) -> list[IntegrationOption]: required=True, help="Directory for command files (e.g. .myagent/commands/)", ), + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help=( + "Render commands as speckit-/SKILL.md directories " + "under --commands-dir instead of flat speckit..md " + "files" + ), + ), ] @staticmethod @@ -84,6 +111,67 @@ def _resolve_commands_dir( "--commands-dir is required for the generic integration" ) + def _build_skill_content( + self, src_file: Path, script_type: str, project_root: Path + ) -> tuple[str, str]: + """Render *src_file* as a SKILL.md body. + + Returns ``(skill_name, content)``. Mirrors the frontmatter and + body shape ``SkillsIntegration.setup()`` produces for other + skills-format agents, so ``speckit-/SKILL.md`` files + emitted here follow the same `agentskills.io + `_ layout. + """ + raw = src_file.read_text(encoding="utf-8") + command_name = src_file.stem + skill_name = f"speckit-{command_name.replace('.', '-')}" + + frontmatter: dict[str, Any] = {} + if raw.startswith("---"): + fm_lines = raw.splitlines(keepends=True) + fm_close = next( + (i for i in range(1, len(fm_lines)) if fm_lines[i].rstrip() == "---"), + None, + ) + if fm_close is not None: + try: + fm = yaml.safe_load("".join(fm_lines[1:fm_close])) + if isinstance(fm, dict): + frontmatter = fm + except yaml.YAMLError: + pass + + processed_body = self.process_template( + raw, self.key, script_type, "$ARGUMENTS", + project_root=project_root, + invoke_separator="-", + ) + if processed_body.startswith("---"): + body_lines = processed_body.splitlines(keepends=True) + close_idx = next( + (i for i in range(1, len(body_lines)) if body_lines[i].rstrip() == "---"), + None, + ) + if close_idx is not None: + processed_body = body_lines[close_idx][3:] + "".join( + body_lines[close_idx + 1:] + ) + + description = frontmatter.get("description") or f"Spec Kit: {command_name} workflow" + skill_content = ( + f"---\n" + f"name: {yaml_quote(skill_name)}\n" + f"description: {yaml_quote(description)}\n" + f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"metadata:\n" + f" author: {yaml_quote('github-spec-kit')}\n" + f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" + f"---\n" + f"{processed_body}" + ) + skill_content = _GenericSkillsHelper().post_process_skill_content(skill_content) + return skill_name, skill_content + def commands_dest(self, project_root: Path) -> Path: """Not supported for GenericIntegration — use setup() directly. @@ -129,9 +217,21 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$ARGUMENTS" + skills_enabled = bool((parsed_options or {}).get("skills")) created: list[Path] = [] for src_file in templates: + if skills_enabled: + skill_name, skill_content = self._build_skill_content( + src_file, script_type, project_root + ) + dst_file = self.write_file_and_record( + skill_content, dest / skill_name / "SKILL.md", + project_root, manifest + ) + created.append(dst_file) + continue + raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 02176be1b0..64ec92e7d3 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -36,11 +36,19 @@ def test_config_requires_cli_false(self): def test_options_include_commands_dir(self): i = get_integration("generic") opts = i.options() - assert len(opts) == 1 + assert len(opts) == 2 assert opts[0].name == "--commands-dir" assert opts[0].required is True assert opts[0].is_flag is False + def test_options_include_skills_flag(self): + i = get_integration("generic") + opts = i.options() + skills_opt = next(o for o in opts if o.name == "--skills") + assert skills_opt.is_flag is True + assert skills_opt.required is False + assert skills_opt.default is False + # -- Setup / teardown ------------------------------------------------- def test_setup_requires_commands_dir(self, tmp_path): @@ -211,6 +219,101 @@ def test_different_commands_dirs(self, tmp_path): cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0 + # -- Skills mode -------------------------------------------------------- + + def test_setup_writes_skill_md_when_skills_flag_set(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/skills", "skills": True}, + ) + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0 + for f in skill_files: + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + assert f.parent.parent == tmp_path / ".myagent" / "skills" + + def test_skill_content_has_expected_frontmatter(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/skills", "skills": True}, + ) + plan_skill = tmp_path / ".myagent" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + content = plan_skill.read_text(encoding="utf-8") + assert content.startswith("---\n") + assert 'name: "speckit-plan"' in content + assert "description:" in content + assert "compatibility:" in content + assert "{SCRIPT}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content + + def test_skill_content_has_hook_command_note(self, tmp_path): + """SKILL.md bodies get the shared dot-to-hyphen hook invocation + note, matching what SkillsIntegration.setup() produces for other + skills-format agents (e.g. Claude).""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/skills", "skills": True}, + ) + constitution_skill = ( + tmp_path / ".myagent" / "skills" / "speckit-constitution" / "SKILL.md" + ) + assert constitution_skill.exists() + content = constitution_skill.read_text(encoding="utf-8") + assert ( + "replace dots (`.`) with hyphens (`-`)" in content + ), "generic --skills output is missing the hook-invocation note" + assert "`speckit.git.commit` → `/speckit-git-commit`" in content + + def test_skills_flag_false_keeps_flat_markdown(self, tmp_path): + """Without --skills, behavior is unchanged: flat speckit..md files.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/commands", "skills": False}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + assert f.name.endswith(".md") + assert f.name.startswith("speckit.") + assert f.parent == tmp_path / ".myagent" / "commands" + + def test_skill_files_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/skills", "skills": True}, + ) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_skills_install_uninstall_roundtrip(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/skills", "skills": True}, + ) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + # -- Context section --------------------------------------------------- def test_setup_does_not_write_context_section(self, tmp_path):