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
104 changes: 102 additions & 2 deletions src/specify_cli/integrations/generic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,35 @@

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-<name>/SKILL.md`` directories under that same
directory instead of flat ``speckit.<name>.md`` files.
"""

from __future__ import annotations

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."""

Expand All @@ -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-<name>/SKILL.md directories "
"under --commands-dir instead of flat speckit.<name>.md "
"files"
),
),
]

@staticmethod
Expand Down Expand Up @@ -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-<name>/SKILL.md`` files
emitted here follow the same `agentskills.io
<https://agentskills.io/specification>`_ 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.

Expand Down Expand Up @@ -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,
Expand Down
105 changes: 104 additions & 1 deletion tests/integrations/test_integration_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.<name>.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):
Expand Down