Compare commits

...

4 Commits

Author SHA1 Message Date
LifeIsAnAbstraction
56095f06d2 fix: use quiet checkout to avoid exception on git checkout (#1792) 2026-03-10 15:41:27 -05:00
Ben Lawson
2632a0f52d feat(extensions): support .extensionignore to exclude files during install (#1781)
* feat(extensions): support .extensionignore to exclude files during install

Add .extensionignore support so extension authors can exclude files and
folders from being copied when users run 'specify extension add'.

The file uses glob-style patterns (one per line), supports comments (#),
blank lines, trailing-slash directory patterns, and relative path matching.
The .extensionignore file itself is always excluded from the copy.

- Add _load_extensionignore() to ExtensionManager
- Integrate ignore function into shutil.copytree in install_from_directory
- Document .extensionignore in EXTENSION-DEVELOPMENT-GUIDE.md
- Add 6 tests covering all pattern matching scenarios
- Bump version to 0.1.14

* fix(extensions): use pathspec for gitignore-compatible .extensionignore matching

Replace fnmatch with pathspec.GitIgnoreSpec to get proper .gitignore
semantics where * does not cross directory boundaries. This addresses
review feedback on #1781.

Changes:
- Switch from fnmatch to pathspec>=0.12.0 (GitIgnoreSpec.from_lines)
- Normalize backslashes in patterns for cross-platform compatibility
- Distinguish directories from files for trailing-slash patterns
- Update docs to accurately describe supported pattern semantics
- Add edge-case tests: .., absolute paths, empty file, backslashes,
  * vs ** boundary behavior, and ! negation
- Move changelog entry to [Unreleased] section
2026-03-10 12:02:04 -05:00
Adrián Luján Muñoz
4ab91fbadf feat: add Codex support for extension command registration (#1767)
* feat: add Codex support for extension command registration

* test: add codex command registrar mapping check

* test: add codex consistency check to test_agent_config_consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:50:42 -05:00
Manfred Riem
5c0bedb410 chore: bump version to 0.2.0 (#1786)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-09 14:54:08 -05:00
7 changed files with 533 additions and 4 deletions

View File

@@ -7,6 +7,48 @@ Recent changes to the Specify CLI and templates are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
## [0.2.0] - 2026-03-09
### Changed
- fix: sync agent list comments with actual supported agents (#1785)
- feat(extensions): support multiple active catalogs simultaneously (#1720)
- Pavel/add tabnine cli support (#1503)
- Add Understanding extension to community catalog (#1778)
- Add ralph extension to community catalog (#1780)
- Update README with project initialization instructions (#1772)
- feat: add review extension to community catalog (#1775)
- Add fleet extension to community catalog (#1771)
- Integration of Mistral vibe support into speckit (#1725)
- fix: Remove duplicate options in specify.md (#1765)
- fix: use global branch numbering instead of per-short-name detection (#1757)
- Add Community Walkthroughs section to README (#1766)
- feat(extensions): add Jira Integration to community catalog (#1764)
- Add Azure DevOps Integration extension to community catalog (#1734)
- Fix docs: update Antigravity link and add initialization example (#1748)
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
- make c ignores consistent with c++ (#1747)
- chore: bump version to 0.1.13 (#1746)
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
- feat: add verify extension to community catalog (#1726)
- Add Retrospective Extension to community catalog README table (#1741)
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
- fix: correct Copilot extension command registration (#1724)
- fix(implement): remove Makefile from C ignore patterns (#1558)
- Add sync extension to community catalog (#1728)
- fix(checklist): clarify file handling behavior for append vs create (#1556)
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
- chore: bump version to 0.1.12 (#1737)
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
- 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

View File

@@ -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

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.1.14"
version = "0.2.0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -13,6 +13,7 @@ dependencies = [
"truststore>=0.10.4",
"pyyaml>=6.0",
"packaging>=23.0",
"pathspec>=0.12.0",
]
[project.scripts]

View File

@@ -250,7 +250,7 @@ if ($branchName.Length -gt $maxBranchLength) {
if ($hasGit) {
$branchCreated = $false
try {
git checkout -b $branchName 2>$null | Out-Null
git checkout -q -b $branchName 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}

View File

@@ -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 = {}
@@ -635,6 +702,12 @@ class CommandRegistrar:
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",

View File

@@ -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")

View File

@@ -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()