diff --git a/CHANGELOG.md b/CHANGELOG.md index 3153ea45..092afefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) - Preset scaffold directory (`presets/scaffold/`) - Scripts updated to use template resolution instead of hardcoded paths +- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) ## [0.2.0] - 2026-03-09 @@ -61,7 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) - ## [0.1.14] - 2026-03-09 ### Added diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index f86beb62..feea7b27 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -332,6 +332,67 @@ echo "$config" --- +## Excluding Files with `.extensionignore` + +Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy. + +### Format + +The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library: + +- Blank lines are ignored +- Lines starting with `#` are comments +- `*` matches anything **except** `/` (does not cross directory boundaries) +- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`) +- `?` matches any single character except `/` +- A trailing `/` restricts a pattern to directories only +- Patterns containing `/` (other than a trailing slash) are anchored to the extension root +- Patterns without `/` match at any depth in the tree +- `!` negates a previously excluded pattern (re-includes a file) +- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility +- The `.extensionignore` file itself is always excluded automatically + +### Example + +```gitignore +# .extensionignore + +# Development files +tests/ +.github/ +.gitignore + +# Build artifacts +__pycache__/ +*.pyc +dist/ + +# Documentation source (keep only the built README) +docs/ +CONTRIBUTING.md +``` + +### Pattern Matching + +| Pattern | Matches | Does NOT match | +|---------|---------|----------------| +| `*.pyc` | Any `.pyc` file in any directory | — | +| `tests/` | The `tests` directory (and all its contents) | A file named `tests` | +| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) | +| `.env` | The `.env` file at any level | — | +| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — | +| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — | + +### Unsupported Features + +The following `.gitignore` features are **not applicable** in this context: + +- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories) +- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here +- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`). + +--- + ## Validation Rules ### Extension ID diff --git a/pyproject.toml b/pyproject.toml index 04b0a338..04a6791a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "truststore>=0.10.4", "pyyaml>=6.0", "packaging>=23.0", + "pathspec>=0.12.0", ] [project.scripts] diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 9b61f424..5184f0f6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -58,6 +58,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "codex": { + "dir": ".codex/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, "windsurf": { "dir": ".windsurf/workflows", "format": "markdown", diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3d1a53c1..03cb3afa 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -14,10 +14,12 @@ import zipfile import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, List, Any, Callable, Set from datetime import datetime, timezone import re +import pathspec + import yaml from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier @@ -280,6 +282,70 @@ class ExtensionManager: self.extensions_dir = project_root / ".specify" / "extensions" self.registry = ExtensionRegistry(self.extensions_dir) + @staticmethod + def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: + """Load .extensionignore and return an ignore function for shutil.copytree. + + The .extensionignore file uses .gitignore-compatible patterns (one per line). + Lines starting with '#' are comments. Blank lines are ignored. + The .extensionignore file itself is always excluded. + + Pattern semantics mirror .gitignore: + - '*' matches anything except '/' + - '**' matches zero or more directories + - '?' matches any single character except '/' + - Trailing '/' restricts a pattern to directories only + - Patterns with '/' (other than trailing) are anchored to the root + - '!' negates a previously excluded pattern + + Args: + source_dir: Path to the extension source directory + + Returns: + An ignore function compatible with shutil.copytree, or None + if no .extensionignore file exists. + """ + ignore_file = source_dir / ".extensionignore" + if not ignore_file.exists(): + return None + + lines: List[str] = ignore_file.read_text().splitlines() + + # Normalise backslashes in patterns so Windows-authored files work + normalised: List[str] = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + normalised.append(stripped.replace("\\", "/")) + else: + # Preserve blanks/comments so pathspec line numbers stay stable + normalised.append(line) + + # Always ignore the .extensionignore file itself + normalised.append(".extensionignore") + + spec = pathspec.GitIgnoreSpec.from_lines(normalised) + + def _ignore(directory: str, entries: List[str]) -> Set[str]: + ignored: Set[str] = set() + rel_dir = Path(directory).relative_to(source_dir) + for entry in entries: + rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry + # Normalise to forward slashes for consistent matching + rel_path_fwd = rel_path.replace("\\", "/") + + entry_full = Path(directory) / entry + if entry_full.is_dir(): + # Append '/' so directory-only patterns (e.g. tests/) match + if spec.match_file(rel_path_fwd + "/"): + ignored.add(entry) + else: + if spec.match_file(rel_path_fwd): + ignored.add(entry) + return ignored + + return _ignore + def check_compatibility( self, manifest: ExtensionManifest, @@ -353,7 +419,8 @@ class ExtensionManager: if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + ignore_fn = self._load_extensionignore(source_dir) + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) # Register commands with AI agents registered_commands = {} diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 607d491d..3615dbc7 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -28,6 +28,13 @@ class TestAgentConfigConsistency: assert cfg["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in cfg + def test_extension_registrar_includes_codex(self): + """Extension command registrar should include codex targeting .codex/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "codex" in cfg + assert cfg["codex"]["dir"] == ".codex/prompts" + def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9ef9cb73..ba52d034 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -407,6 +407,11 @@ class TestCommandRegistrar: assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in CommandRegistrar.AGENT_CONFIGS + def test_codex_agent_config_present(self): + """Codex should be mapped to .codex/prompts.""" + assert "codex" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- @@ -1598,3 +1603,343 @@ class TestCatalogStack: assert len(results) == 1 assert results[0]["_catalog_name"] == "org" assert results[0]["_install_allowed"] is True + + +class TestExtensionIgnore: + """Test .extensionignore support during extension installation.""" + + def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None): + """Helper to create an extension directory with optional extra files and .extensionignore.""" + import yaml + + ext_dir = temp_dir / "ignored-ext" + ext_dir.mkdir() + + # Write manifest + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(valid_manifest_data, f) + + # Create commands directory with a command file + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + (commands_dir / "hello.md").write_text( + "---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n" + ) + + # Create any extra files/dirs + if extra_files: + for rel_path, content in extra_files.items(): + p = ext_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + if content is None: + # Create directory + p.mkdir(parents=True, exist_ok=True) + else: + p.write_text(content) + + # Write .extensionignore + if ignore_content is not None: + (ext_dir / ".extensionignore").write_text(ignore_content) + + return ext_dir + + def test_no_extensionignore(self, temp_dir, valid_manifest_data): + """Without .extensionignore, all files are copied.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"}, + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "tests" / "test_foo.py").exists() + + def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data): + """Files matching .extensionignore patterns are excluded.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "tests/test_foo.py": "pass", + "tests/test_bar.py": "pass", + ".github/workflows/ci.yml": "on: push", + }, + ignore_content="tests/\n.github/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Included + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + # Excluded + assert not (dest / "tests").exists() + assert not (dest / ".github").exists() + + def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data): + """Glob patterns like *.pyc are respected.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "helpers.pyc": b"\x00".decode("latin-1"), + "commands/cache.pyc": b"\x00".decode("latin-1"), + }, + ignore_content="*.pyc\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "helpers.pyc").exists() + assert not (dest / "commands" / "cache.pyc").exists() + + def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data): + """Comments and blank lines in .extensionignore are ignored.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "some notes"}, + ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "notes.txt").exists() + + def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data): + """.extensionignore is never copied to the destination.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + ignore_content="# nothing special here\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "extension.yml").exists() + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data): + """Patterns matching relative paths work correctly.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal/draft.md": "draft", + "README.md": "# Hello", + }, + ignore_content="docs/internal/draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Patterns with '..' should not escape the extension root.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello"}, + ignore_content="../sibling/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Everything should still be copied — the '..' pattern matches nothing inside + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + + def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Absolute path patterns should not match anything.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "passwd": "sensitive"}, + ignore_content="/etc/passwd\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir + assert (dest / "README.md").exists() + assert (dest / "passwd").exists() + + def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data): + """An empty .extensionignore should exclude only itself.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "notes"}, + ignore_content="", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "notes.txt").exists() + assert (dest / "extension.yml").exists() + # .extensionignore itself is still excluded + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data): + """Backslash patterns (Windows-style) are normalised to forward slashes.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/internal/draft.md": "draft", + "docs/guide.md": "# Guide", + }, + ignore_content="docs\\internal\\draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data): + """'*' should NOT match across directory boundaries (gitignore semantics).""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + }, + ignore_content="docs/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.draft.md should only match directly inside docs/, NOT subdirs + assert not (dest / "docs" / "api.draft.md").exists() + assert (dest / "docs" / "sub" / "api.draft.md").exists() + + def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data): + """'**' should match across directory boundaries.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + "docs/guide.md": "guide", + }, + ignore_content="docs/**/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert not (dest / "docs" / "api.draft.md").exists() + assert not (dest / "docs" / "sub" / "api.draft.md").exists() + assert (dest / "docs" / "guide.md").exists() + + def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): + """'!' negation re-includes a previously excluded file.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal.md": "internal", + "docs/api.md": "api", + }, + ignore_content="docs/*.md\n!docs/api.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.md excludes all .md in docs, but !docs/api.md re-includes it + assert not (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal.md").exists() + assert (dest / "docs" / "api.md").exists()