mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* fix(integrations): use native dollar skill invocations Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve skill post-process idempotence Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve literal skill invocations Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve shared invocation prefix Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve install invocation prefix Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve dollar refs everywhere Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): preserve dollar command hints Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): render native helper prefixes Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(skills): use invocation-neutral hook guidance Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * test(integrations): expect native fallback invocation Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * refactor(integrations): centralize invocation prefix selection Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): add Kimi /skill: prefix and fix docstrings - Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to _invocation_style.py so Kimi resolves to '/skill:' in skills mode - Switch invoke_prefix_for_integration() to use get_invocation_prefix() instead of the binary dollar/slash check - Update post_process_skill_content docstring (base.py) to cover both slash and dollar native invocation forms - Update _resolve_command_refs_in_skill docstring (presets/__init__.py) to document the dollar-prefixed result alongside slash forms Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): use get_invocation_prefix for Kimi in register_commands Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to /skill:speckit-<name> rather than /speckit-<name>. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): remove unused is_dollar_skills_agent import Leftover from replacing the inline ternary with get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(integrations): use get_invocation_prefix in post_process_skill_content Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix so that Kimi's hook-command note is injected as /skill:speckit-git-commit from the start. This keeps _inject_hook_command_note idempotent for Kimi: the previous note with its native prefix now matches on repeated passes, preventing duplicate note injection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(presets): use get_invocation_prefix in _resolve_skill_command_refs Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-* (which previously relied on the broad post-process body replacement). Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver rather than bypassing it with a pre-resolved string. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * docs(presets): document /skill: form in _resolve_skill_command_refs Add /skill:speckit-<cmd> to the docstring so the contract covers all three native prefix forms returned by get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * test(integrations): add Kimi /skill: prefix coverage - test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix - test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill: for kimi (skills), / for kimi (non-skills), $ for codex, / for claude - test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command note is injected with /skill: prefix and does not duplicate on re-runs - test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash formatter outputs /skill:speckit-plan when installed with /skill: prefix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(kimi): use get_invocation_prefix in process_template, remove broad replacement process_template() was still using a binary is_dollar_skills_agent ternary to select between dollar and slash prefix, so Kimi tokens were emitted as /speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-') in KimiIntegration.post_process_skill_content(). That broad replacement would also rewrite any literal /speckit-* text in generated skill content, contrary to the PR's token-only behavior. - Use get_invocation_prefix(agent_name, invoke_separator == '-') in process_template() so Kimi tokens are emitted as /skill:speckit-* directly. - Remove the broad .replace() from KimiIntegration.post_process_skill_content(); it is now a no-op (tokens are already correctly prefixed at source). - Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
490 lines
20 KiB
Python
490 lines
20 KiB
Python
"""Reusable test mixin for standard SkillsIntegration subclasses.
|
|
|
|
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
|
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
|
``SkillsIntegrationTests``.
|
|
|
|
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
|
|
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
|
|
"""
|
|
|
|
import os
|
|
|
|
import yaml
|
|
|
|
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
|
from specify_cli.integrations.base import SkillsIntegration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
|
|
class SkillsIntegrationTests:
|
|
"""Mixin — set class-level constants and inherit these tests.
|
|
|
|
Required class attrs on subclass::
|
|
|
|
KEY: str — integration registry key
|
|
FOLDER: str — e.g. ".agents/"
|
|
COMMANDS_SUBDIR: str — e.g. "skills"
|
|
REGISTRAR_DIR: str — e.g. ".agents/skills"
|
|
"""
|
|
|
|
KEY: str
|
|
FOLDER: str
|
|
COMMANDS_SUBDIR: str
|
|
REGISTRAR_DIR: str
|
|
|
|
# -- Registration -----------------------------------------------------
|
|
|
|
def test_registered(self):
|
|
assert self.KEY in INTEGRATION_REGISTRY
|
|
assert get_integration(self.KEY) is not None
|
|
|
|
def test_is_skills_integration(self):
|
|
assert isinstance(get_integration(self.KEY), SkillsIntegration)
|
|
|
|
# -- Config -----------------------------------------------------------
|
|
|
|
def test_config_folder(self):
|
|
i = get_integration(self.KEY)
|
|
assert i.config["folder"] == self.FOLDER
|
|
|
|
def test_config_commands_subdir(self):
|
|
i = get_integration(self.KEY)
|
|
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
|
|
|
def test_registrar_config(self):
|
|
i = get_integration(self.KEY)
|
|
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
|
assert i.registrar_config["format"] == "markdown"
|
|
assert i.registrar_config["args"] == "$ARGUMENTS"
|
|
assert i.registrar_config["extension"] == "/SKILL.md"
|
|
|
|
# -- Setup / teardown -------------------------------------------------
|
|
|
|
def test_setup_creates_files(self, tmp_path):
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
assert len(created) > 0
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
for f in skill_files:
|
|
assert f.exists()
|
|
assert f.name == "SKILL.md"
|
|
assert f.parent.name.startswith("speckit-")
|
|
|
|
def test_setup_writes_to_correct_directory(self, tmp_path):
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
expected_dir = i.skills_dest(tmp_path)
|
|
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
assert len(skill_files) > 0, "No skill files were created"
|
|
for f in skill_files:
|
|
# Each SKILL.md is in speckit-<name>/ under the skills directory
|
|
assert f.resolve().parent.parent == expected_dir.resolve(), (
|
|
f"{f} is not under {expected_dir}"
|
|
)
|
|
|
|
def test_skill_directory_structure(self, tmp_path):
|
|
"""Each command produces speckit-<name>/SKILL.md."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
|
|
expected_commands = {
|
|
"analyze", "clarify", "constitution", "converge", "implement",
|
|
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
|
}
|
|
|
|
# Derive command names from the skill directory names
|
|
actual_commands = set()
|
|
for f in skill_files:
|
|
skill_dir_name = f.parent.name # e.g. "speckit-plan"
|
|
assert skill_dir_name.startswith("speckit-")
|
|
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
|
|
|
assert actual_commands == expected_commands
|
|
|
|
def test_skill_frontmatter_structure(self, tmp_path):
|
|
"""SKILL.md must have name, description, compatibility, metadata."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
|
parts = content.split("---", 2)
|
|
fm = yaml.safe_load(parts[1])
|
|
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
|
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
|
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
|
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
|
assert fm["metadata"]["author"] == "github-spec-kit"
|
|
assert "source" in fm["metadata"]
|
|
|
|
def test_skill_uses_template_descriptions(self, tmp_path):
|
|
"""SKILL.md should use the original template description for ZIP parity."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
parts = content.split("---", 2)
|
|
fm = yaml.safe_load(parts[1])
|
|
# Description must be a non-empty string (from the template)
|
|
assert isinstance(fm["description"], str)
|
|
assert len(fm["description"]) > 0, f"{f} has empty description"
|
|
|
|
def test_templates_are_processed(self, tmp_path):
|
|
"""Skill body must have placeholders replaced, not raw templates."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
|
|
|
def test_command_refs_use_hyphen_separator(self, tmp_path):
|
|
"""Skills agents must resolve command refs with hyphen separator."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
# Skills agents must use /speckit-<name>, not /speckit.<name>
|
|
assert "/speckit." not in content, (
|
|
f"{f.name} contains dot-notation /speckit. reference; "
|
|
f"skills agents must use /speckit-<name>"
|
|
)
|
|
|
|
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
|
"""Generated skills with hook sections must explain dotted command conversion."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
i.setup(tmp_path, m)
|
|
specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md"
|
|
assert specify_skill.exists()
|
|
content = specify_skill.read_text(encoding="utf-8")
|
|
assert "replace dots" in content, (
|
|
"speckit-specify should explain dotted hook command conversion"
|
|
)
|
|
assert content.count("replace dots") == content.count(
|
|
"- For each executable hook, output the following"
|
|
)
|
|
|
|
def test_hook_note_injected_for_each_instruction_independently(self):
|
|
"""Existing hook notes should not suppress later missing notes."""
|
|
content = (
|
|
"---\n"
|
|
"name: test\n"
|
|
"---\n\n"
|
|
"- When constructing command invocations from hook command names, "
|
|
"replace dots (`.`) with hyphens (`-`). "
|
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
|
"- For each executable hook, output the following first block:\n"
|
|
"\n"
|
|
"- For each executable hook, output the following second block:\n"
|
|
)
|
|
|
|
result = SkillsIntegration._inject_hook_command_note(content)
|
|
|
|
assert result.count("replace dots (`.`) with hyphens") == 2
|
|
|
|
def test_skill_body_has_content(self, tmp_path):
|
|
"""Each SKILL.md body should contain template content after the frontmatter."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
# Body is everything after the second ---
|
|
parts = content.split("---", 2)
|
|
body = parts[2].strip() if len(parts) >= 3 else ""
|
|
assert len(body) > 0, f"{f} has empty body"
|
|
|
|
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
|
"""The generated plan skill must not carry a context-file placeholder.
|
|
|
|
Agent context files are owned entirely by the opt-in agent-context
|
|
extension, so the core plan skill must not reference one.
|
|
"""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
i.setup(tmp_path, m)
|
|
plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md"
|
|
assert plan_file.exists(), f"Plan skill {plan_file} not created"
|
|
content = plan_file.read_text(encoding="utf-8")
|
|
assert "__CONTEXT_FILE__" not in content, (
|
|
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
|
)
|
|
|
|
def test_all_files_tracked_in_manifest(self, tmp_path):
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.setup(tmp_path, m)
|
|
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_install_uninstall_roundtrip(self, tmp_path):
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.install(tmp_path, m)
|
|
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 == []
|
|
|
|
def test_modified_file_survives_uninstall(self, tmp_path):
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
created = i.install(tmp_path, m)
|
|
m.save()
|
|
modified_file = created[0]
|
|
modified_file.write_text("user modified this", encoding="utf-8")
|
|
removed, skipped = i.uninstall(tmp_path, m)
|
|
assert modified_file.exists()
|
|
assert modified_file in skipped
|
|
|
|
def test_pre_existing_skills_not_removed(self, tmp_path):
|
|
"""Pre-existing non-speckit skills should be left untouched."""
|
|
i = get_integration(self.KEY)
|
|
skills_dir = i.skills_dest(tmp_path)
|
|
foreign_dir = skills_dir / "other-tool"
|
|
foreign_dir.mkdir(parents=True)
|
|
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
|
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
i.setup(tmp_path, m)
|
|
|
|
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
|
|
|
|
# -- Context file ownership (extension-owned, opt-in) -----------------
|
|
|
|
def test_setup_does_not_write_context_section(self, tmp_path):
|
|
"""Setup must not create or manage any agent context file — that is
|
|
owned entirely by the opt-in agent-context extension."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
i.setup(tmp_path, m)
|
|
for path in tmp_path.rglob("*"):
|
|
if path.is_file():
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
assert "<!-- SPECKIT START -->" not in text, (
|
|
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
|
)
|
|
|
|
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
|
"""A user-authored context file must survive setup + teardown untouched."""
|
|
i = get_integration(self.KEY)
|
|
m = IntegrationManifest(self.KEY, tmp_path)
|
|
ctx_path = tmp_path / "AGENTS.md"
|
|
original = "# My Rules\n\nUser content.\n"
|
|
ctx_path.write_text(original, encoding="utf-8")
|
|
i.setup(tmp_path, m)
|
|
m.save()
|
|
i.teardown(tmp_path, m)
|
|
assert ctx_path.read_text(encoding="utf-8") == original
|
|
|
|
# -- CLI integration flag -------------------------------------------------
|
|
|
|
def test_integration_flag_auto_promotes(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / f"promote-{self.KEY}"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
|
i = get_integration(self.KEY)
|
|
skills_dir = i.skills_dest(project)
|
|
assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
|
|
|
|
def test_integration_flag_creates_files(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / f"int-{self.KEY}"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
|
i = get_integration(self.KEY)
|
|
skills_dir = i.skills_dest(project)
|
|
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
|
|
|
def test_init_does_not_create_agent_context_config(self, tmp_path):
|
|
"""agent-context is opt-in: init must not auto-install the extension
|
|
or write its config."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / f"opts-{self.KEY}"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
|
assert not ext_cfg_path.exists()
|
|
|
|
# -- IntegrationOption ------------------------------------------------
|
|
|
|
def test_options_include_skills_flag(self):
|
|
i = get_integration(self.KEY)
|
|
opts = i.options()
|
|
skills_opts = [o for o in opts if o.name == "--skills"]
|
|
assert len(skills_opts) == 1
|
|
assert skills_opts[0].is_flag is True
|
|
|
|
# -- Complete file inventory ------------------------------------------
|
|
|
|
_SKILL_COMMANDS = [
|
|
"analyze", "clarify", "constitution", "converge", "implement",
|
|
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
|
]
|
|
|
|
def _expected_files(self, script_variant: str) -> list[str]:
|
|
"""Build the full expected file list for a given script variant."""
|
|
i = get_integration(self.KEY)
|
|
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
|
|
|
files = []
|
|
# Skill files (core commands)
|
|
for cmd in self._SKILL_COMMANDS:
|
|
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
|
# Integration metadata
|
|
files += [
|
|
".specify/init-options.json",
|
|
".specify/integration.json",
|
|
f".specify/integrations/{self.KEY}.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/memory/.constitution-template.json",
|
|
".specify/memory/constitution.md",
|
|
]
|
|
# Script variant
|
|
if script_variant == "sh":
|
|
files += [
|
|
".specify/scripts/bash/check-prerequisites.sh",
|
|
".specify/scripts/bash/common.sh",
|
|
".specify/scripts/bash/create-new-feature.sh",
|
|
".specify/scripts/bash/setup-plan.sh",
|
|
".specify/scripts/bash/setup-tasks.sh",
|
|
]
|
|
else:
|
|
files += [
|
|
".specify/scripts/powershell/check-prerequisites.ps1",
|
|
".specify/scripts/powershell/common.ps1",
|
|
".specify/scripts/powershell/create-new-feature.ps1",
|
|
".specify/scripts/powershell/setup-plan.ps1",
|
|
".specify/scripts/powershell/setup-tasks.ps1",
|
|
]
|
|
# Templates
|
|
files += [
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
]
|
|
# Bundled workflow
|
|
files += [
|
|
".specify/workflows/speckit/workflow.yml",
|
|
".specify/workflows/workflow-registry.json",
|
|
]
|
|
return sorted(files)
|
|
|
|
def test_complete_file_inventory_sh(self, tmp_path):
|
|
"""Every file produced by specify init --integration <key> --script sh."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / f"inventory-sh-{self.KEY}"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
actual = sorted(
|
|
p.relative_to(project).as_posix()
|
|
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
|
)
|
|
expected = self._expected_files("sh")
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|
|
|
|
def test_complete_file_inventory_ps(self, tmp_path):
|
|
"""Every file produced by specify init --integration <key> --script ps."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / f"inventory-ps-{self.KEY}"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
actual = sorted(
|
|
p.relative_to(project).as_posix()
|
|
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
|
)
|
|
expected = self._expected_files("ps")
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|