diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index bb25b3fc1..8f3a03a32 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,7 +7,7 @@ """ from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re @@ -150,6 +150,50 @@ def rewrite_project_relative_paths(text: str) -> str: return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") + @staticmethod + def rewrite_extension_paths(text: str, extension_id: str, extension_dir: Path) -> str: + """Rewrite extension-relative paths to their installed project locations. + + Extension command bodies reference files using paths relative to the + extension root (e.g. ``agents/control/commander.md``). After install, + those files live at ``.specify/extensions//...``. This method + rewrites such references so that AI agents can locate them after install. + + Only directories that actually exist inside *extension_dir* are rewritten, + keeping the behaviour conservative and avoiding false positives on prose. + + Args: + text: Body text of the command file. + extension_id: The extension identifier (e.g. ``"echelon"``). + extension_dir: Path to the installed extension directory. + + Returns: + Body text with extension-relative paths expanded. + """ + if not isinstance(text, str) or not text: + return text + + _SKIP = {"commands", ".git"} + try: + subdirs = [ + d.name + for d in extension_dir.iterdir() + if d.is_dir() and d.name not in _SKIP + ] + except OSError: + return text + + base_prefix = f".specify/extensions/{extension_id}/" + for subdir in subdirs: + escaped = re.escape(subdir) + text = re.sub( + r"(^|[\s`\"'(])(?:\.?/)?" + escaped + r"/", + r"\1" + base_prefix + subdir + "/", + text, + ) + + return text + def render_markdown_command( self, frontmatter: dict, @@ -229,6 +273,7 @@ def render_skill_command( source_id: str, source_file: str, project_root: Path, + source_dir: Optional[Path] = None, ) -> str: """Render a command override as a SKILL.md file. @@ -245,6 +290,9 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} + if source_dir is not None: + body = self.rewrite_extension_paths(body, source_id, source_dir) + if agent_name in {"codex", "kimi"}: body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) @@ -424,7 +472,8 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": output = self.render_skill_command( - agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) elif agent_config["format"] == "markdown": output = self.render_markdown_command(frontmatter, body, source_id, context_note) @@ -452,7 +501,8 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root + agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) elif agent_config["format"] == "markdown": alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) @@ -465,7 +515,8 @@ def register_commands( alias_output = output if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 350b368ea..951a5c779 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1209,6 +1209,205 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content assert ".specify/scripts/bash/update-agent-context.sh codex" in content + def test_skill_registration_rewrites_extension_relative_paths(self, project_dir, temp_dir): + """Extension subdirectory paths in command bodies should be rewritten to + .specify/extensions//... in generated SKILL.md files.""" + import yaml + + ext_dir = temp_dir / "ext-multidir" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + (ext_dir / "templates").mkdir() + (ext_dir / "scripts").mkdir() + (ext_dir / "knowledge-base").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-multidir", + "name": "Multi-Dir Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-multidir.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + "Use templates/report.md as output format.\n" + "Run scripts/bash/gate.sh to validate.\n" + "Load knowledge-base/scores.yaml for calibration.\n" + "Also check memory/constitution.md for project rules.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-ext-multidir-run" / "SKILL.md").read_text() + # Extension-owned directories → extension-local paths + assert ".specify/extensions/ext-multidir/agents/control/commander.md" in content + assert ".specify/extensions/ext-multidir/templates/report.md" in content + assert ".specify/extensions/ext-multidir/scripts/bash/gate.sh" in content + assert ".specify/extensions/ext-multidir/knowledge-base/scores.yaml" in content + # memory/ is not an extension directory, so stays project-level + assert "memory/constitution.md" in content + # No bare extension-relative path references remain + assert "Read agents/" not in content + assert "Load knowledge-base/" not in content + + def test_skill_registration_rewrites_extension_relative_paths_for_kimi(self, project_dir, temp_dir): + """Path rewriting should also apply to kimi, which uses the /SKILL.md extension.""" + import yaml + + ext_dir = temp_dir / "ext-kimi-paths" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + (ext_dir / "knowledge-base").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-kimi-paths", + "name": "Kimi Paths Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-kimi-paths.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + "Load knowledge-base/scores.yaml for calibration.\n" + ) + + skills_dir = project_dir / ".kimi" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("kimi", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-ext-kimi-paths-run" / "SKILL.md").read_text() + assert ".specify/extensions/ext-kimi-paths/agents/control/commander.md" in content + assert ".specify/extensions/ext-kimi-paths/knowledge-base/scores.yaml" in content + assert "Read agents/" not in content + + def test_skill_registration_rewrites_paths_in_aliases(self, project_dir, temp_dir): + """Alias SKILL.md files should also have extension-relative paths rewritten.""" + import yaml + + ext_dir = temp_dir / "ext-alias-paths" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-alias-paths", + "name": "Alias Paths Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-alias-paths.run", + "file": "commands/run.md", + "description": "Run command", + "aliases": ["speckit.ext-alias-paths.go"], + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + alias_content = (skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md").read_text() + assert ".specify/extensions/ext-alias-paths/agents/control/commander.md" in alias_content + assert "Read agents/" not in alias_content + + def test_rewrite_extension_paths_no_subdirs(self, project_dir, temp_dir): + """Extension with no subdirectories should leave command body text unchanged.""" + import yaml + + ext_dir = temp_dir / "bare-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": {"id": "bare-ext", "name": "Bare", "version": "1.0.0", "description": "Test"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{"name": "speckit.bare-ext.run", "file": "commands/run.md", "description": "Run"}]}, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\ndescription: Run\n---\n\nRead agents/control/commander.md and templates/report.md.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + CommandRegistrar().register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-bare-ext-run" / "SKILL.md").read_text() + # No subdirs to match — text unchanged + assert "agents/control/commander.md" in content + assert "templates/report.md" in content + def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" import yaml diff --git a/tests/test_integration_extension_skill_paths.py b/tests/test_integration_extension_skill_paths.py new file mode 100644 index 000000000..0456a3d24 --- /dev/null +++ b/tests/test_integration_extension_skill_paths.py @@ -0,0 +1,214 @@ +""" +Integration tests: install a real extension into a temp project and verify +that generated SKILL.md files have correct .specify/extensions//… paths +instead of bare extension-relative references. + +Set the SPECKIT_TEST_EXT_DIR environment variable to the path of a local +extension checkout before running. Tests are skipped automatically when +the variable is not set or the directory does not exist. + +Example: + SPECKIT_TEST_EXT_DIR=~/work/my-extension pytest tests/test_integration_extension_skill_paths.py +""" + +import json +import os +import re +import shutil +import tempfile +from pathlib import Path + +import pytest + +_ext_dir_env = os.environ.get("SPECKIT_TEST_EXT_DIR", "") +EXT_DIR = Path(_ext_dir_env).expanduser().resolve() if _ext_dir_env else None + +pytestmark = pytest.mark.skipif( + EXT_DIR is None or not EXT_DIR.exists(), + reason="Set SPECKIT_TEST_EXT_DIR to an extension checkout to run these tests", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _ext_id() -> str: + from specify_cli.extensions import ExtensionManifest + return ExtensionManifest(EXT_DIR / "extension.yml").id + + +def _make_project(tmp: Path, ai: str = "codex") -> Path: + project = tmp / "project" + project.mkdir() + specify = project / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": ai, "ai_skills": True, "script": "sh"}) + ) + if ai == "codex": + (project / ".agents" / "skills").mkdir(parents=True) + elif ai == "kimi": + (project / ".kimi" / "skills").mkdir(parents=True) + return project + + +def _install_ext(project: Path) -> None: + from specify_cli.extensions import ExtensionManager + try: + from importlib.metadata import version + speckit_version = version("specify-cli") + except Exception: + speckit_version = "999.0.0" + ExtensionManager(project).install_from_directory(EXT_DIR, speckit_version, register_commands=True) + + +def _skill_files(project: Path, ext_id: str, ai: str = "codex") -> dict[str, Path]: + skills_root = project / (".agents/skills" if ai == "codex" else ".kimi/skills") + return { + p.parent.name: p + for p in skills_root.glob("*/SKILL.md") + if ext_id in p.parent.name + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def ext_id(): + return _ext_id() + + +@pytest.fixture +def tmp_dir(): + d = tempfile.mkdtemp() + yield Path(d) + shutil.rmtree(d) + + +@pytest.fixture +def codex_project(tmp_dir): + project = _make_project(tmp_dir, ai="codex") + _install_ext(project) + return project + + +@pytest.fixture +def kimi_project(tmp_dir): + project = _make_project(tmp_dir, ai="kimi") + _install_ext(project) + return project + + +# --------------------------------------------------------------------------- +# Installation sanity +# --------------------------------------------------------------------------- + +class TestExtensionInstallation: + + def test_extension_files_copied_to_specify_dir(self, codex_project, ext_id): + installed = codex_project / ".specify" / "extensions" / ext_id + assert installed.is_dir() + assert (installed / "extension.yml").exists() + + def test_agent_subdirectory_installed(self, codex_project, ext_id): + installed = codex_project / ".specify" / "extensions" / ext_id + subdirs = [d.name for d in installed.iterdir() if d.is_dir()] + assert subdirs, f"No subdirectories found under {installed}" + + def test_all_commands_produce_skill_files(self, codex_project, ext_id): + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest( + codex_project / ".specify" / "extensions" / ext_id / "extension.yml" + ) + skill_files = _skill_files(codex_project, ext_id) + for cmd in manifest.commands: + short = cmd["name"].removeprefix("speckit.").replace(".", "-") + skill_name = f"speckit-{short}" + assert skill_name in skill_files, ( + f"Expected SKILL.md for '{cmd['name']}' at '{skill_name}'.\n" + f"Available: {sorted(skill_files)}" + ) + + def test_registry_records_installed_extension(self, codex_project, ext_id): + from specify_cli.extensions import ExtensionManager + assert ExtensionManager(codex_project).registry.is_installed(ext_id) + + +# --------------------------------------------------------------------------- +# Path rewriting +# --------------------------------------------------------------------------- + +class TestSkillPathRewriting: + + def test_installed_subdirs_appear_with_extension_prefix(self, codex_project, ext_id): + """At least one installed subdirectory should appear prefixed in skill files.""" + installed = codex_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(codex_project, ext_id) + all_content = "\n".join(p.read_text() for p in skill_files.values()) + + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + rewritten = [s for s in installed_subdirs if f"{prefix}{s}/" in all_content] + assert rewritten, ( + f"No installed subdir appeared as {prefix}/ in any skill file.\n" + f"Installed subdirs: {installed_subdirs}" + ) + + def test_no_bare_subdir_paths_remain(self, codex_project, ext_id): + """No bare '/…' references should survive in any skill file.""" + installed = codex_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(codex_project, ext_id) + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + failures = [] + for subdir in installed_subdirs: + for name, path in skill_files.items(): + stripped = path.read_text().replace(f"{prefix}{subdir}/", "__OK__") + bare = re.findall( + r'(?:^|[\s`"\'(])(?:\.?/)?' + re.escape(subdir) + r'/', + stripped, re.MULTILINE, + ) + if bare: + failures.append(f"{name}: bare '{subdir}/': {bare}") + assert not failures, "Bare subdirectory references found:\n" + "\n".join(failures) + + +# --------------------------------------------------------------------------- +# Kimi +# --------------------------------------------------------------------------- + +class TestSkillPathRewritingKimi: + + def test_kimi_skills_contain_extension_prefix(self, kimi_project, ext_id): + installed = kimi_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(kimi_project, ext_id, ai="kimi") + assert skill_files, f"No kimi skill files found for {ext_id}" + + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + all_content = "\n".join(p.read_text() for p in skill_files.values()) + rewritten = [s for s in installed_subdirs if f"{prefix}{s}/" in all_content] + assert rewritten, ( + f"No installed subdir appeared as {prefix}/ in kimi skill files.\n" + f"Installed subdirs: {installed_subdirs}" + ) + + +# --------------------------------------------------------------------------- +# Script placeholders +# --------------------------------------------------------------------------- + +class TestScriptPlaceholders: + + def test_no_unresolved_script_placeholders(self, codex_project, ext_id): + skill_files = _skill_files(codex_project, ext_id) + failures = [] + for name, path in skill_files.items(): + content = path.read_text() + for placeholder in ("{SCRIPT}", "{AGENT_SCRIPT}", "{ARGS}"): + if placeholder in content: + failures.append(f"{name}: contains {placeholder}") + assert not failures, "Unresolved placeholders:\n" + "\n".join(failures)