From 588c9048653ad7a66fdc5d49855db8af9b271484 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sat, 12 Sep 2026 14:27:31 +0000 Subject: [PATCH 1/3] fix: support SKILL.md rendering for the generic integration The generic (bring-your-own-agent) escape hatch could only ever emit flat speckit..md command files, with no way to opt into the speckit-/SKILL.md layout every skills-format agent (Claude, Codex, etc.) uses. Add a --skills flag to --integration-options that renders the same command templates as SKILL.md directories under --commands-dir instead, matching the agentskills.io layout used elsewhere. Default behavior (flat .md files) is unchanged. Fixes #4561 --- .../integrations/generic/__init__.py | 90 ++++++++++++++++++- .../integrations/test_integration_generic.py | 85 +++++++++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index f8ea47ccb2..b3fdf88ffd 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,7 +12,9 @@ from pathlib import Path from typing import Any -from ..base import IntegrationOption, MarkdownIntegration +import yaml + +from ..base import IntegrationOption, MarkdownIntegration, yaml_quote from ..manifest import IntegrationManifest @@ -40,6 +44,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 +98,66 @@ 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}" + ) + return skill_name, skill_content + def commands_dest(self, project_root: Path) -> Path: """Not supported for GenericIntegration — use setup() directly. @@ -129,9 +203,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..02958daac5 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,81 @@ 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_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): From 5533447e18893fde53a021df23eae313ca552836 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sat, 12 Sep 2026 14:38:01 +0000 Subject: [PATCH 2/3] fix: apply hook-invocation note to generic --skills SKILL.md output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_skill_content() duplicated SkillsIntegration.setup()'s per-file body but dropped the call to post_process_skill_content(), which injects the dot-to-hyphen hook-invocation note before every "For each executable hook" instruction. Without it, a configured extension hook (e.g. speckit.git.commit) would be invoked verbatim as /speckit.git.commit, which doesn't exist under the speckit-/SKILL.md layout this feature introduces. Add a small _GenericSkillsHelper(SkillsIntegration) — the same delegation pattern CopilotIntegration uses for its own skills mode — and call its post_process_skill_content() after building the SKILL.md body. Add a regression test asserting the note appears. --- .../integrations/generic/__init__.py | 16 ++++++++++++++- .../integrations/test_integration_generic.py | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index b3fdf88ffd..e5c9f9e2de 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -14,10 +14,23 @@ import yaml -from ..base import IntegrationOption, MarkdownIntegration, yaml_quote +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.""" @@ -156,6 +169,7 @@ def _build_skill_content( 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: diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 02958daac5..64ec92e7d3 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -253,6 +253,26 @@ def test_skill_content_has_expected_frontmatter(self, tmp_path): 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") From 0e6767c6f918ccc5109c639adda655a477e07452 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Tue, 15 Sep 2026 17:21:56 +0000 Subject: [PATCH 3/3] fix: align generic --skills invocation separator and disable add-on skill registration Reviewer feedback on #4562: generic --skills persisted ai_skills=True but rendered shared templates and init's next-step guidance with the dotted /speckit.plan separator, and let extension/preset registration silently fall back to writing add-on skills under .agents/skills instead of the user's --commands-dir. GenericIntegration now overrides effective_invoke_separator() to match the layout it actually writes, "generic" is classified as a conditional-slash agent for next-step display, and resolve_active_skills_dir() explicitly stays disabled for generic in both layouts since its output directory is a runtime option, not a static per-agent folder. --- docs/reference/integrations.md | 4 +- src/specify_cli/__init__.py | 9 ++ src/specify_cli/_invocation_style.py | 1 + .../integrations/generic/__init__.py | 11 +++ .../integrations/test_integration_generic.py | 88 +++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 551f73c97e..cb4eec3905 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -46,7 +46,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | | [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | | [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | -| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above; add `--skills` for the `speckit-/SKILL.md` layout | ## List Available Integrations @@ -237,6 +237,7 @@ Some integrations accept additional options via `--integration-options`: | Integration | Option | Description | | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | +| `generic` | `--skills` | Render commands as `speckit-/SKILL.md` directories under `--commands-dir` instead of flat `speckit..md` files. Command references and next-step guidance switch to `/speckit-`. Generic's output directory is a runtime option rather than a static per-agent folder, so this does not enable extension/preset add-on skill registration in either layout. | | `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) | | `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. | | `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. | @@ -245,6 +246,7 @@ Example: ```bash specify integration install generic --integration-options="--commands-dir .myagent/cmds" +specify integration install generic --integration-options="--commands-dir .myagent/skills --skills" ``` ## Scaffold a New Integration diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 93f10a1950..9840698975 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -311,6 +311,15 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None: if not isinstance(agent, str) or not agent: return None + # generic's output directory is a runtime --commands-dir CLI option, not + # a static per-agent folder (its config["folder"] is None), so there is + # no directory extension/preset skill registration could safely resolve + # here even when the project was scaffolded with --skills. Registration + # stays disabled for generic in both layouts, matching flat-mode generic + # (which never persists ai_skills=True and so never reaches this point). + if agent == "generic": + return None + ai_skills_enabled = _is_ai_skills_enabled(opts) if not ai_skills_enabled and agent != "kimi": return None diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 9fa115212f..bd01a9f7bb 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -24,6 +24,7 @@ "claude", "copilot", "cursor-agent", + "generic", "hermes", "lingma", "rovodev", diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index e5c9f9e2de..fbceec20cb 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -49,6 +49,17 @@ class GenericIntegration(MarkdownIntegration): "extension": ".md", } + def effective_invoke_separator( + self, + parsed_options: dict[str, Any] | None = None, + project_root: Path | None = None, + ) -> str: + """``"-"`` for the ``--skills`` SKILL.md layout, ``"."`` for the + default flat ``speckit..md`` layout — mirrors the separator + ``_build_skill_content()`` already uses to process each template. + """ + return "-" if self.is_skills_mode(parsed_options, project_root) else "." + @classmethod def options(cls) -> list[IntegrationOption]: return [ diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 64ec92e7d3..0b65802822 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -467,6 +467,94 @@ def test_complete_file_inventory_sh(self, tmp_path): f"Extra: {sorted(set(actual) - set(expected))}" ) + # -- Skills-mode alignment (separator, next-steps, add-on registration) -- + + def test_effective_invoke_separator_tracks_skills_flag(self, tmp_path): + """The separator used to render shared templates and next-step + guidance must match the layout ``setup()`` actually writes.""" + i = get_integration("generic") + assert i.effective_invoke_separator({"skills": True}, tmp_path) == "-" + assert i.effective_invoke_separator({"skills": False}, tmp_path) == "." + assert i.effective_invoke_separator(None, tmp_path) == "." + + def test_shared_template_and_next_steps_use_hyphen_in_skills_mode(self, tmp_path): + """End-to-end: with --skills, shared templates and the printed next + steps must reference /speckit-plan (the layout actually generated), + not the nonexistent flat /speckit.plan.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "generic-skills-e2e" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--integration-options=--commands-dir .myagent/skills --skills", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + + plan_template = project / ".specify" / "templates" / "plan-template.md" + content = plan_template.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_PLAN__" not in content + assert "/speckit-plan" in content + assert "/speckit.plan" not in content + + assert "/speckit-plan" in result.output + assert "/speckit.plan" not in result.output + + def test_shared_template_and_next_steps_use_dot_without_skills_flag( + self, tmp_path + ): + """Regression guard: default flat-mode generic is unchanged — shared + templates and next steps still reference the flat /speckit.plan.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "generic-flat-e2e" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--integration-options=--commands-dir .myagent/commands", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + + plan_template = project / ".specify" / "templates" / "plan-template.md" + content = plan_template.read_text(encoding="utf-8") + assert "/speckit.plan" in content + assert "/speckit-plan" not in content + + assert "/speckit.plan" in result.output + assert "/speckit-plan" not in result.output + + def test_generic_skills_mode_does_not_register_addon_skills_elsewhere( + self, tmp_path + ): + """Copilot review (PR #4562): a generic --skills project persists + ai_skills=True, but generic's output directory is a runtime + --commands-dir option, not a static per-agent folder — there is no + directory extension/preset skill registration could safely resolve. + resolve_active_skills_dir() must stay disabled for generic rather + than silently falling back to .agents/skills.""" + from specify_cli import resolve_active_skills_dir + from specify_cli._init_options import save_init_options + + save_init_options( + tmp_path, {"ai": "generic", "ai_skills": True} + ) + assert resolve_active_skills_dir(tmp_path) is None + assert not (tmp_path / ".agents" / "skills").exists() + def test_complete_file_inventory_ps(self, tmp_path): """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script ps.""" from typer.testing import CliRunner