Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .apm/architecture/owners/contracts-tooling.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
"owner": "compilation/footer.py (build_generation_footer)",
"selectors": ["src/apm_cli/compilation/footer.py"],
"guards": ["contracts-tooling-generation-footer"]
},
{
"id": "apmignore-membership",
"decision": "Package ship/deploy/compile path membership from .apmignore",
"owner": "utils/apmignore.py (ApmIgnoreSpec)",
"selectors": ["src/apm_cli/utils/apmignore.py"],
"guards": ["contracts-tooling-apmignore-membership"]
}
]
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Packages can ship a `.apmignore` file (gitignore semantics, including nested
files and `!` negation) so `apm install`, `apm pack`, and `apm compile`
omit maintainer-only paths such as `evals/`. Root `SKILL.md` and `apm.yml`
cannot be ignored.

### Changed

- Architecture ownership guards now use a sharded JSON registry and a
Expand Down
389 changes: 389 additions & 0 deletions NOTICE

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/src/content/docs/concepts/package-anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ my-pkg/
+-- apm-policy.yml # Optional org/repo policy. See enterprise docs.
+-- scripts/ # Optional helper scripts you author.
+-- tests/ # Optional tests for your primitives.
+-- .apmignore # Optional. Omit maintainer-only files from
# install, pack, and compile (gitignore syntax).
```

Anything under `apm_modules/`, `.github/`, `.claude/`, `.cursor/`, or
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/producer/author-primitives/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ required file; the four conventional subdirectories ship as-is when
APM copies the skill to a target. Single-skill repositories may also
place `SKILL.md` at the package root.

A root `SKILL.md` package copies the whole tree on install. Put a
`.apmignore` next to it (gitignore syntax) to keep maintainer-only
paths such as `evals/` out of install, pack, and compile. You cannot
ignore `SKILL.md` or `apm.yml`.

## Frontmatter contract

```yaml
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/producer/pack-a-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ This is the producer side of [Deploy a local bundle](../../consumer/deploy-a-bun
Consumers who receive the artifact run `apm install ./your-bundle` and skip
the registry resolver entirely.

To keep maintainer-only files such as `evals/` out of the bundle, add a
`.apmignore` at the package root. The file uses gitignore syntax. `apm pack`,
`apm install`, and `apm compile` all honor it.

## What `apm pack` produces

By default `apm pack` writes a Claude Code plugin directory under `./build/`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ my-package/

## Install-time discovery rules

Add a `.apmignore` at the package root (gitignore syntax, including
nested files and `!` negation) to keep maintainer-only files such as
`evals/` out of `apm install`, `apm pack`, and `apm compile`. There
are no built-in author patterns. `SKILL.md` and `apm.yml` cannot be
ignored. The git checkout in `apm_modules/` stays complete; filtering
happens at deploy, pack, and compile.

When `.apm/` exists, `apm pack` sources local primitives and hooks from
`.apm/`. Without `.apm/`, supported plugin-native root directories
(`agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"ruamel.yaml>=0.18.0",
"filelock>=3.12",
"websockets>=12,<17",
"pathspec>=0.12.0",
]

[project.optional-dependencies]
Expand Down
96 changes: 96 additions & 0 deletions scripts/architecture_linter/checks/contracts_apmignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Package ship/deploy/compile path membership from ``.apmignore``."""

from __future__ import annotations

from scripts.architecture_linter.facts import FactsProvider
from scripts.architecture_linter.groups.common import checked_facts, violation
from scripts.architecture_linter.models import FileFacts, Violation

_OWNER = "src/apm_cli/utils/apmignore.py"
_CONSTANTS = "src/apm_cli/constants.py"
_CLASS = "ApmIgnoreSpec"
_FILENAME = ".apmignore"
_SRC_PREFIX = "src/apm_cli/"


def _pathspec_import_site(facts: FileFacts) -> tuple[int, int] | None:
"""Return the first pathspec / GitIgnoreSpec import coordinate, if any."""
for item in facts.imports:
module = item.module or ""
if module == "pathspec" or module.startswith("pathspec."):
return item.line, item.column + 1
if "GitIgnoreSpec" in item.names:
return item.line, item.column + 1
return None


def check_apmignore_membership(
provider: FactsProvider,
rule_id: str,
) -> tuple[Violation, ...]:
"""Require ``.apmignore`` parsing to stay in utils/apmignore.py."""
findings: list[Violation] = []
owner, failures = checked_facts(provider, _OWNER, rule_id, require_python=True)
findings.extend(failures)
if not failures:
definitions = tuple(
definition
for definition in owner.definitions
if definition.name == _CLASS
and definition.kind == "class"
and definition.scope == "<module>"
)
if len(definitions) != 1:
findings.append(
violation(
rule_id,
_OWNER,
f"{_CLASS} must have exactly one module-level class definition",
line=1,
)
)
if _pathspec_import_site(owner) is None:
findings.append(
violation(
rule_id,
_OWNER,
"apmignore owner must import pathspec.GitIgnoreSpec",
line=1,
)
)

for path in provider.inventory:
if path == _OWNER or not path.startswith(_SRC_PREFIX) or not path.endswith(".py"):
continue
facts, failures = checked_facts(provider, path, rule_id, require_python=True)
findings.extend(failures)
if failures:
continue
if path != _CONSTANTS:
for literal in facts.literals:
if _FILENAME in literal.value_repr:
findings.append(
violation(
rule_id,
path,
f"{_FILENAME} filename must stay in {_OWNER}",
line=literal.line,
column=literal.column + 1,
)
)
imported = _pathspec_import_site(facts)
if imported is not None:
line, column = imported
findings.append(
violation(
rule_id,
path,
"pathspec GitIgnoreSpec parsing must stay in utils/apmignore.py",
line=line,
column=column,
)
)
return tuple(findings)


__all__ = ["check_apmignore_membership"]
11 changes: 11 additions & 0 deletions scripts/architecture_linter/checks/contracts_test_taxonomy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
import re
from collections.abc import Sequence

from scripts.architecture_linter.checks.contracts_apmignore import (
check_apmignore_membership,
)
from scripts.architecture_linter.checks.contracts_generation_footer import (
check_generation_footer_authority,
)
Expand Down Expand Up @@ -68,6 +71,9 @@
_GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer"


_GUARD_APMIGNORE = "contracts-tooling-apmignore-membership"


_SRC_PREFIX = "src/apm_cli/"


Expand Down Expand Up @@ -595,6 +601,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule:
"Generated-content footer wording stays owned by compilation/footer.py.",
lambda provider: check_generation_footer_authority(provider, _GUARD_GENERATION_FOOTER),
),
_owner_rule(
_GUARD_APMIGNORE,
"Package ship/deploy/compile path membership stays owned by utils/apmignore.py.",
lambda provider: check_apmignore_membership(provider, _GUARD_APMIGNORE),
),
_structural_rule(
_CONTRACT_RULE_ID,
"Executable test binary selection, rendered CLI parity, and ratchet authority owners.",
Expand Down
6 changes: 6 additions & 0 deletions scripts/notice-metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,9 @@ components:
spdx: MIT
copyright_snippet: Copyright (c) 2022 Seth Michael Larson
notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy.
- name: pathspec
pyproject_name: pathspec
upstream: https://github.com/cpburnz/python-pathspec
spdx: MPL-2.0
copyright_snippet: Copyright (c) Caleb P. Burns
notes: Used by ApmIgnoreSpec to match .apmignore files with gitignore semantics.
35 changes: 27 additions & 8 deletions src/apm_cli/bundle/plugin_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)
from ..models.apm_package import APMPackage, DependencyReference
from ..models.dependency.subsets import skill_subset_filter_tokens
from ..utils.apmignore import ApmIgnoreSpec
from ..utils.archive import (
projected_archive_path,
validate_archive_format,
Expand Down Expand Up @@ -148,26 +149,30 @@ def _collect_apm_components(apm_dir: Path) -> list[tuple[Path, str]]:
if not apm_dir.is_dir():
return components

ignore = ApmIgnoreSpec.load(apm_dir.parent)

# agents/ -> agents/
_collect_flat(apm_dir / "agents", "agents", components)
_collect_flat(apm_dir / "agents", "agents", components, ignore=ignore)

# skills/ -> skills/ (preserve sub-directory structure)
_collect_recursive(apm_dir / "skills", "skills", components)
_collect_recursive(apm_dir / "skills", "skills", components, ignore=ignore)

# prompts/ -> commands/ (rename .prompt.md -> .md)
_collect_recursive(apm_dir / "prompts", "commands", components, rename=_rename_prompt)
_collect_recursive(
apm_dir / "prompts", "commands", components, rename=_rename_prompt, ignore=ignore
)

# instructions/ -> instructions/
_collect_recursive(apm_dir / "instructions", "instructions", components)
_collect_recursive(apm_dir / "instructions", "instructions", components, ignore=ignore)

# commands/ -> commands/
_collect_recursive(apm_dir / "commands", "commands", components)
_collect_recursive(apm_dir / "commands", "commands", components, ignore=ignore)

# extensions/ -> extensions/ (canvas extensions, experimental Copilot-only).
# Preserved verbatim so an offline bundle can carry a canvas; the files are
# inert until the consumer enables the ``canvas`` experimental flag AND
# approves the package via allowExecutables / ``apm approve`` at install time.
_collect_recursive(apm_dir / "extensions", "extensions", components)
_collect_recursive(apm_dir / "extensions", "extensions", components, ignore=ignore)

return components

Expand All @@ -179,10 +184,11 @@ def _collect_root_plugin_components(project_root: Path) -> list[tuple[Path, str]
``skills/``, etc. at the repo root) have their files picked up here.
"""
components: list[tuple[Path, str]] = []
ignore = ApmIgnoreSpec.load(project_root)
for dir_name in PLUGIN_ROOT_DIRS:
if dir_name == "hooks":
continue
_collect_recursive(project_root / dir_name, dir_name, components)
_collect_recursive(project_root / dir_name, dir_name, components, ignore=ignore)
return components


Expand Down Expand Up @@ -245,6 +251,7 @@ def _collect_bare_skill(
slug = _normalize_bare_skill_slug(getattr(dep, "virtual_path", "") or "")
if not slug:
slug = dep.repo_url.rsplit("/", 1)[-1] if dep.repo_url else "skill"
ignore = ApmIgnoreSpec.load(install_path)
for f in sorted(install_path.iterdir()):
if (
f.is_file()
Expand All @@ -255,6 +262,7 @@ def _collect_bare_skill(
"apm.lock.yaml",
"plugin.json",
)
and not ignore.is_ignored(f, is_dir=False)
):
out.append((f, f"skills/{slug}/{f.name}"))

Expand All @@ -268,12 +276,17 @@ def _collect_flat(
out: list[tuple[Path, str]],
*,
rename=None,
ignore: ApmIgnoreSpec | None = None,
) -> None:
"""Add every regular non-symlink file directly inside *src_dir*."""
if src_dir.is_symlink() or not src_dir.is_dir():
return
for f in sorted(src_dir.iterdir()):
if f.is_file() and not f.is_symlink():
if (
f.is_file()
and not f.is_symlink()
and not (ignore and ignore.is_ignored(f, is_dir=False))
):
name = rename(f.name) if rename else f.name
out.append((f, f"{output_prefix}/{name}"))

Expand All @@ -284,13 +297,16 @@ def _collect_recursive(
out: list[tuple[Path, str]],
*,
rename=None,
ignore: ApmIgnoreSpec | None = None,
) -> None:
"""Add every regular non-symlink file under *src_dir*, preserving hierarchy."""
if src_dir.is_symlink() or not src_dir.is_dir():
return
for f in sorted(src_dir.rglob("*")):
if not f.is_file() or f.is_symlink():
continue
if ignore is not None and ignore.is_ignored(f, is_dir=False):
continue
rel = f.relative_to(src_dir)
name = rename(rel.name) if rename else rel.name
out_rel = (rel.parent / name).as_posix()
Expand Down Expand Up @@ -583,6 +599,7 @@ def _collect_explicit_local_components(
components: list[tuple[Path, str]] = []
hooks: dict = {}
hooks_present = False
ignore = ApmIgnoreSpec.load(project_root)
for declared_path in includes:
parts = _deployed_path_parts(declared_path)
candidate = project_root.joinpath(*parts)
Expand Down Expand Up @@ -611,6 +628,8 @@ def _collect_explicit_local_components(
f"{entry.name}. Remove the symlink or list a regular path."
)
for file_path in (entry for entry in entries if entry.is_file()):
if ignore.is_ignored(file_path, is_dir=False):
continue
try:
file_path = ensure_path_within(file_path, project_root)
except PathTraversalError as exc:
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class InstallMode(Enum):
GITHUB_DIR = ".github"
CLAUDE_DIR = ".claude"
GITIGNORE_FILENAME = ".gitignore"
APM_IGNORE_FILENAME = ".apmignore"
APM_MODULES_GITIGNORE_PATTERN = "apm_modules/"


Expand Down
Loading