diff --git a/integrations/catalog.json b/integrations/catalog.json index 16e321cf58..87b4c49a41 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,8 +1,17 @@ { "schema_version": "1.0", - "updated_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-05-28T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { + "alquimia": { + "id": "alquimia", + "name": "Alquimia AI", + "version": "1.0.0", + "description": "Alquimia AI CLI integration", + "author": "Alquimia AI team", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "alquimia"] + }, "claude": { "id": "claude", "name": "Claude Code", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 4a78e7d035..2a76cbf273 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -48,6 +48,7 @@ def _register_builtins() -> None: """ # -- Imports (alphabetical) ------------------------------------------- from .agy import AgyIntegration + from .alquimia_ai import AlquimiaAIIntegration from .amp import AmpIntegration from .auggie import AuggieIntegration from .bob import BobIntegration @@ -80,6 +81,7 @@ def _register_builtins() -> None: # -- Registration (alphabetical) -------------------------------------- _register(AgyIntegration()) + _register(AlquimiaAIIntegration()) _register(AmpIntegration()) _register(AuggieIntegration()) _register(BobIntegration()) diff --git a/src/specify_cli/integrations/alquimia_ai/__init__.py b/src/specify_cli/integrations/alquimia_ai/__init__.py new file mode 100644 index 0000000000..5e6c68bfd3 --- /dev/null +++ b/src/specify_cli/integrations/alquimia_ai/__init__.py @@ -0,0 +1,238 @@ +"""Alquimia AI integration.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +from ..base import SkillsIntegration +from ..manifest import IntegrationManifest + +# Note injected into hook sections so Alquimia maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Alquimia AI. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + + +class AlquimiaAIIntegration(SkillsIntegration): + """Integration for Alquimia AI skills.""" + + key = "alquimia" + config = { + "name": "Alquimia AI", + "folder": ".alquimia/", + "commands_subdir": "skills", + "install_url": "https://docs.alquimia.ai", + "requires_cli": True, + } + registrar_config = { + "dir": ".alquimia/skills", + "format": "markdown", + "args": "{{query}}", + "extension": "/SKILL.md", + } + context_file = "ALQUIMIA.md" + multi_install_safe = True + + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content # already present + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + return "".join(out) + + def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: + """Render a processed command template as a Alquimia skill.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + description = frontmatter.get( + "description", + f"Spec-kit workflow command: {template_name}", + ) + skill_frontmatter = self._build_skill_fm( + skill_name, description, f"templates/commands/{template_name}.md" + ) + frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() + return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + + def _build_skill_fm(self, name: str, description: str, source: str) -> dict: + from specify_cli.agents import CommandRegistrar + return CommandRegistrar.build_skill_frontmatter( + self.key, name, description, source + ) + + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Insert ``key: value`` before the closing ``---`` if not already present.""" + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") + injected = True + out.append(line) + return "".join(out) + + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) or '\n' + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + def post_process_skill_content(self, content: str) -> str: + """Inject Alquimia-specific frontmatter flags and hook notes.""" + updated = self._inject_frontmatter_flag(content, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + updated = self._inject_hook_command_note(updated) + return updated + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Alquimia skills, then inject Alquimia-specific flags and argument-hints.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + updated = self.post_process_skill_content(content) + + # Inject argument-hint if available for this skill + skill_dir_name = path.parent.name # e.g. "speckit-plan" + stem = skill_dir_name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py new file mode 100644 index 0000000000..9d34025683 --- /dev/null +++ b/tests/integrations/test_integration_alquimia.py @@ -0,0 +1,559 @@ +"""Tests for AlquimiaAIIntegration.""" + +import codecs +import json +import os +from unittest.mock import patch + +import yaml +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.alquimia_ai import ARGUMENT_HINTS +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestAlquimiaAIIntegration: + def test_registered(self): + assert "alquimia" in INTEGRATION_REGISTRY + assert get_integration("alquimia") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("alquimia"), IntegrationBase) + + def test_config_uses_skills(self): + integration = get_integration("alquimia") + assert integration.config["folder"] == ".alquimia/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("alquimia") + assert integration.registrar_config["dir"] == ".alquimia/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "{{query}}" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + integration = get_integration("alquimia") + assert integration.context_file == "ALQUIMIA.md" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("alquimia") + manifest = IntegrationManifest("alquimia", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".alquimia" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" + assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True + assert parsed["disable-model-invocation"] is False + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_setup_upserts_context_section(self, tmp_path): + integration = get_integration("alquimia") + manifest = IntegrationManifest("alquimia", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + ctx_path = tmp_path / integration.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_upsert_context_section_strips_bom(self, tmp_path): + """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + integration = get_integration("alquimia") + ctx_path = tmp_path / integration.context_file + + # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) + bom = codecs.BOM_UTF8 + ctx_path.write_bytes(bom + b"# ALQUIMIA.md\n\nSome existing content.\n") + + integration.upsert_context_section(tmp_path) + + result = ctx_path.read_bytes() + assert not result.startswith(bom), "BOM must be stripped after upsert" + content = result.decode("utf-8") + assert "" in content + assert "Some existing content." in content + + def test_remove_context_section_strips_bom(self, tmp_path): + """remove_context_section must clean BOM from context file on Windows-authored files.""" + integration = get_integration("alquimia") + ctx_path = tmp_path / integration.context_file + + marker_content = ( + "# ALQUIMIA.md\n\n" + "\n" + "For additional context about technologies to be used, project structure,\n" + "shell commands, and other important information, read the current plan\n" + "\n" + ) + ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) + + result = integration.remove_context_section(tmp_path) + + assert result is True + assert ctx_path.exists(), "File should exist (non-empty content remains)" + remaining = ctx_path.read_bytes() + assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" + assert b"