Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`apm compile -t copilot` now emits `.github/copilot-instructions.md` with zero user configuration** -- APM's first Copilot-native compile target. Global instructions in `.apm/instructions/` are assembled into the file VS Code and GitHub Copilot read automatically; switching targets cleans it up. APM dogfoods this target. (#1048)
- **`apm marketplace add` accepts full HTTPS URLs and nested HOST/group/sub/.../REPO shorthands.** You can now paste a repository URL straight from the browser (e.g., `apm marketplace add https://github.com/acme/plugin-marketplace`) and register marketplaces hosted under nested sub-paths on GitHub Enterprise (`ghes.corp.example.com/org/team/repo`). Path-traversal sequences in the parsed segments are rejected via `validate_path_segments`. Non-GitHub hosts (GitLab, Bitbucket, etc.) are explicitly rejected at registration time with an actionable error -- this avoids forwarding GitHub credentials to unintended hosts and the silent fetch-time 404 that previously resulted; native non-GitHub support is tracked separately. (#1034, closes #1027)
- Regression tests for `apm compile` placement of narrow `applyTo` patterns: instructions whose matches all live deep inside one subtree are now pinned to the deepest covering directory instead of being hoisted to the project root, across both selective and single-point placement strategies. Also covers the file-walk cache that skips repeated filesystem scans for the same glob. (#871)
- **`apm pack` marketplace builder hardening.** Local source paths are now emitted relative to `metadata.pluginRoot` (fixes double-prefix bug). New pass-through fields: `author`, `license`, `repository`, `keywords` (alias for `tags`). Curator-wins override semantics for `description`/`version` on remote entries. Security guards reject path traversal and absolute paths post-subtraction. (#1061)
Expand All @@ -24,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm compile --dry-run -t copilot` now faithfully simulates the hand-authored file guard: a `.github/copilot-instructions.md` lacking the APM marker is reported as `skipped=1` (matching the real run) instead of as `generated=1`. Previously dry-run would claim a write that a real run would refuse, giving CI preview gates a false signal. (#1048)
- `apm compile -t claude,copilot` (and any multi-target list including `copilot`) now correctly generates `.github/copilot-instructions.md`; previously it was silently skipped on the multi-target code path. (#1048)
- `apm compile` no longer overwrites a hand-authored `.github/copilot-instructions.md`; if the file lacks the APM-generated marker, regeneration is skipped with a warning that names the literal marker line (`<!-- Generated by APM CLI from .apm/ primitives -->`) so users can self-serve recovery. **Migration:** to adopt APM management of an existing file, either delete or rename it and re-run `apm compile`, or prepend the marker line to the top of the file and re-run `apm compile`. (#1048)
- Generated footer in `.github/copilot-instructions.md` now reads `apm compile` (was `specify apm compile`). (#1048)
- `apm compile --targets claude` no longer lists `@apm_modules/{owner}/{package}/CLAUDE.md` dependencies for packages that don't have a `CLAUDE.md` file on disk (#1047)
- **`apm prune` no longer flags directories it put there itself** -- skills installed from a subdirectory path (e.g., `owner/repo/.apm/skills/skill-name`) no longer cause the parent `owner/repo/` clone to appear as an orphan. Fixes spurious removal prompts in multi-skill and monorepo-style setups. The same fix applies to `apm deps list` and `apm compile`. A genuinely orphaned `owner/repo` package is still flagged even when a sibling subdirectory dep shares the same `owner/repo` root. No changes to `apm.yml` or the lockfile are required to benefit from this fix. (#1050)

## [0.11.0] - 2026-04-29
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ apm install vercel-labs/agent-skills --skill deploy-to-vercel # one skill, per

Same install gesture. You also get a [manifest, lockfile, and reproducibility](https://microsoft.github.io/apm/reference/package-types/#skill-collection-skillsnameskillmd).

**Zero-config Copilot:**

```bash
apm compile -t copilot # writes .github/copilot-instructions.md
```

One command, no configuration -- VS Code and GitHub Copilot read the file automatically. APM dogfoods this target on its own repository.

## The three promises

### 1. Portable by manifest
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/enterprise/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ download → scan source → block or deploy → report

Content scanning extends beyond install:

- **`apm compile`** scans compiled output (AGENTS.md, CLAUDE.md, commands) before writing to disk. Critical findings cause `apm compile` to exit with code 1 after writing — defense-in-depth since source files were already scanned at install, but compilation assembles content from multiple sources.
- **`apm compile`** scans compiled output (AGENTS.md, CLAUDE.md, `.github/copilot-instructions.md`, commands) before writing to disk. Critical findings cause `apm compile` to exit with code 1 after writing — defense-in-depth since source files were already scanned at install, but compilation assembles content from multiple sources. `.github/copilot-instructions.md` is assembled from global instructions in `.apm/instructions/`, including those installed under `apm_modules/`.
- **`apm pack`** scans files before bundling. This catches hidden characters before a package is published, preventing authors from accidentally distributing tainted content.
- **`apm unpack`** scans bundle contents before deployment. This is a pre-deployment gate matching `apm install` — critical findings block deployment unless `--force` is used.

Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ dependencies:
- microsoft/apm-sample-package#v1.0.0
```

## Get Copilot reading your packages in under a minute

Run one more command:

```bash
apm compile -t copilot
```

APM assembles every global instruction it just installed into `.github/copilot-instructions.md` -- the file VS Code and GitHub Copilot read automatically. No configuration, no extra setup; open the project in VS Code and Copilot is already grounded in your packages' standards.

## That's it

Open your editor. GitHub Copilot, Claude, Cursor, and OpenCode pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package.
Expand Down
7 changes: 5 additions & 2 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1748,13 +1748,13 @@ apm compile --watch
apm compile --watch --dry-run

# Target specific agent formats
apm compile --target vscode # AGENTS.md + .github/ only
apm compile --target vscode # AGENTS.md + .github/ (incl. copilot-instructions.md)
apm compile --target claude # CLAUDE.md + .claude/ only
apm compile --target opencode # AGENTS.md + .opencode/ only
apm compile --target all # All formats (default)

# Multiple targets (comma-separated)
apm compile -t claude,copilot # Both CLAUDE.md and AGENTS.md
apm compile -t claude,copilot # CLAUDE.md + AGENTS.md + .github/copilot-instructions.md

# Compile injecting Spec Kit constitution (auto-detected)
apm compile --with-constitution
Expand All @@ -1779,6 +1779,9 @@ apm compile --no-constitution
**Content Scanning:**
Compiled output is scanned for hidden Unicode characters before writing to disk. Critical findings cause `apm compile` to exit with code 1 — defense-in-depth since source files are already scanned during `apm install`.

**`.github/copilot-instructions.md` generation:**
When the resolved target is `copilot` (alias `vscode`), `all`, or any multi-target list containing `copilot`, `apm compile` assembles all *global* instructions (entries in `.apm/instructions/` without an `apply_to` field) into `.github/copilot-instructions.md` -- the file VS Code and GitHub Copilot read automatically with zero user configuration. Generated content is wrapped with an APM-only marker (literal first line: `<!-- Generated by APM CLI from .apm/ primitives -->`). Switching to a non-Copilot target (e.g. `apm compile -t claude`) cleans up the file only when the marker is present; a hand-authored `.github/copilot-instructions.md` is left untouched on both write and cleanup paths. To adopt APM management of an existing hand-authored file, delete (or rename) it and re-run `apm compile`, or prepend the marker line `<!-- Generated by APM CLI from .apm/ primitives -->` to the top of the file and re-run `apm compile`.

**Configuration Integration:**
The compile command supports configuration via `apm.yml`:

Expand Down
32 changes: 27 additions & 5 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,25 +185,47 @@ def _resolve_compile_target(target):
return None # will trigger detect_target() auto-detection
if isinstance(target, list):
target_set = set(target)
agents_family = {"copilot", "vscode", "agents", "cursor", "opencode", "codex"}
has_agents_family = bool(target_set & agents_family)
# Two distinct families overlap on copilot/vscode/agents:
# copilot_family -> requests .github/copilot-instructions.md AND AGENTS.md
# agents_md_family -> requests AGENTS.md only (cursor/opencode/codex)
# Splitting these prevents the over-fire bug where -t cursor,claude or
# -t cursor,opencode,codex used to incorrectly emit copilot-instructions.md.
copilot_family = {"copilot", "vscode", "agents"}
agents_md_family = {"cursor", "opencode", "codex"}
has_copilot = bool(target_set & copilot_family)
has_agents_md_only = bool(target_set & agents_md_family)
has_claude = "claude" in target_set
has_gemini = "gemini" in target_set
families = set()
if has_agents_family:
families.add("agents")
if has_copilot:
families.add("vscode") # gates copilot-instructions.md
families.add("agents") # also gates AGENTS.md
elif has_agents_md_only:
families.add("agents") # AGENTS.md only -- no copilot-instructions
if has_claude:
families.add("claude")
if has_gemini:
families.add("gemini")
if len(families) >= 2:
# Single-target copilot collapses {"vscode","agents"} to bare "vscode"
# for routing parity with single-string -t copilot.
if families == {"vscode", "agents"}:
return "vscode"
return frozenset(families)
elif has_claude:
return "claude"
elif has_gemini:
return "gemini"
elif has_copilot:
return "vscode"
else:
return "vscode" # agents-family only
# cursor/opencode/codex only -- preserve the bare target name so
# single-element list routing matches single-string semantics
# (-t cursor and -t cursor both end up as "cursor").
for bare in ("cursor", "opencode", "codex"):
if bare in target_set:
return bare
return "vscode" # defensive fallback (unreachable)
return target # single string pass-through


Expand Down
61 changes: 50 additions & 11 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
# Compiler families allowed inside a multi-target frozenset (built by
# _resolve_compile_target() from CLI-validated target names). Kept narrow
# because the frozenset path bypasses _KNOWN_TARGETS validation.
_KNOWN_COMPILE_FAMILIES = frozenset({"agents", "claude", "gemini"})
_KNOWN_COMPILE_FAMILIES = frozenset({"agents", "vscode", "claude", "gemini"})


@dataclass
Expand Down Expand Up @@ -883,7 +883,14 @@ def _maybe_emit_copilot_root_instructions(
primitives: PrimitiveCollection,
result: CompilationResult,
) -> CompilationResult:
"""Generate .github/copilot-instructions.md for Copilot-capable targets."""
"""Generate .github/copilot-instructions.md for Copilot-capable targets.

Skip semantics: if the file already exists without the APM-generated
marker, it is treated as hand-authored and left untouched. The
``copilot_root_instructions_skipped`` stat captures this case
explicitly so callers can distinguish it from a genuine no-op
(``copilot_root_instructions_unchanged``) or an unrouted target.
"""
routing_target = "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target
output_path = self.base_dir / ".github" / "copilot-instructions.md"
if not should_compile_copilot_instructions_md(routing_target):
Expand All @@ -892,6 +899,7 @@ def _maybe_emit_copilot_root_instructions(
result.stats.setdefault("copilot_root_instructions_generated", 0)
result.stats.setdefault("copilot_root_instructions_written", 0)
result.stats.setdefault("copilot_root_instructions_unchanged", 0)
result.stats.setdefault("copilot_root_instructions_skipped", 0)
result.stats.setdefault("copilot_root_instructions_removed", 0)
return result

Expand All @@ -905,17 +913,54 @@ def _maybe_emit_copilot_root_instructions(
result.stats.setdefault("copilot_root_instructions_generated", 0)
result.stats.setdefault("copilot_root_instructions_written", 0)
result.stats.setdefault("copilot_root_instructions_unchanged", 0)
result.stats.setdefault("copilot_root_instructions_skipped", 0)
result.stats.setdefault("copilot_root_instructions_removed", 0)
return result

content = self._generate_copilot_root_instructions_content(global_instructions, config)

result.stats["copilot_root_instructions_generated"] = 1
result.stats.setdefault("copilot_root_instructions_skipped", 0)
result.stats.setdefault("copilot_root_instructions_removed", 0)
result.stats.setdefault("copilot_root_instructions_written", 0)
result.stats.setdefault("copilot_root_instructions_unchanged", 0)

# Inspect any existing file BEFORE the dry-run early-exit so that
# `--dry-run` faithfully reports what a real run would do (skip vs
# write vs unchanged). Reading the file here is safe in dry-run mode
# because we never mutate it.
try:
existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None
except OSError as exc:
message = f"Failed to read {output_path}: {exc}"
self.errors.append(message)
result.errors.append(message)
result.success = False
return result

if existing is not None and _COPILOT_ROOT_GENERATED_MARKER not in existing:
rel_path = portable_relpath(output_path, self.base_dir)
result.warnings.append(
f"Skipped {rel_path}: hand-authored file will not be overwritten. "
"To regenerate, either delete or rename it, or prepend the line "
f"'{_COPILOT_ROOT_GENERATED_MARKER}' to the top of the file. "
"Then re-run 'apm compile'."
)
# The file was never compared to new content; record as
# 'skipped', not 'unchanged'. Also reset 'generated' since no
# output was actually emitted (or would be, on a real run).
result.stats["copilot_root_instructions_generated"] = 0
result.stats["copilot_root_instructions_written"] = 0
result.stats["copilot_root_instructions_skipped"] = 1
result.stats["copilot_root_instructions_unchanged"] = 0
return result

if existing == content:
result.stats["copilot_root_instructions_written"] = 0
result.stats["copilot_root_instructions_unchanged"] = 1
return result

if config.dry_run:
result.stats.setdefault("copilot_root_instructions_written", 0)
result.stats.setdefault("copilot_root_instructions_unchanged", 0)
return result

from ..security.gate import WARN_POLICY, SecurityGate
Expand All @@ -932,12 +977,6 @@ def _maybe_emit_copilot_root_instructions(

try:
output_path.parent.mkdir(parents=True, exist_ok=True)
existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None
if existing == content:
result.stats["copilot_root_instructions_written"] = 0
result.stats["copilot_root_instructions_unchanged"] = 1
return result

output_path.write_text(content, encoding="utf-8")
result.stats["copilot_root_instructions_written"] = 1
result.stats["copilot_root_instructions_unchanged"] = 0
Expand Down Expand Up @@ -973,7 +1012,7 @@ def _generate_copilot_root_instructions_content(

sections.append("---")
sections.append("*This file was generated by APM CLI. Do not edit manually.*")
sections.append("*To regenerate: `specify apm compile`*")
sections.append("*To regenerate: `apm compile`*")
sections.append("")

content = "\n".join(sections)
Expand Down
6 changes: 5 additions & 1 deletion src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def _collect_dependencies(self) -> builtins.list[str]:
dependencies = []
apm_modules_dir = self.base_dir / "apm_modules"

if not apm_modules_dir.exists():
if not apm_modules_dir.is_dir():
return dependencies

# Scan for CLAUDE.md files in apm_modules
Expand All @@ -225,6 +225,10 @@ def _collect_dependencies(self) -> builtins.list[str]:
if not package_dir.is_dir() or package_dir.name.startswith("."):
continue

claude_md_path = package_dir / "CLAUDE.md"
if not claude_md_path.is_file():
continue
Comment thread
tillig marked this conversation as resolved.

# Build the @import path
import_path = f"@apm_modules/{owner_dir.name}/{package_dir.name}/CLAUDE.md"
dependencies.append(import_path)
Expand Down
27 changes: 24 additions & 3 deletions src/apm_cli/core/target_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@
# Compiler families used inside a multi-target frozenset. Narrower than
# TargetType because the families are produced by _resolve_compile_target()
# (in the compile CLI) from CLI-validated target names.
CompileFamily = Literal["agents", "claude", "gemini"]
#
# Family semantics:
# "agents" -> AGENTS.md is generated (any of copilot/vscode/agents/cursor/
# opencode/codex was requested)
# "vscode" -> .github/copilot-instructions.md is generated (only when
# copilot/vscode/agents was specifically requested -- NOT for
# cursor/opencode/codex which use their own native config files)
# "claude" -> CLAUDE.md is generated
# "gemini" -> GEMINI.md is generated
CompileFamily = Literal["agents", "vscode", "claude", "gemini"]

# Compile target: either a single TargetType string or a frozenset of compiler
# families ({"agents", "claude", "gemini"}) for multi-target lists.
Expand Down Expand Up @@ -196,15 +205,27 @@ def should_compile_gemini_md(target: CompileTargetType) -> bool:
return target in ("gemini", "all")


def should_compile_copilot_instructions_md(target: TargetType) -> bool:
def should_compile_copilot_instructions_md(target: CompileTargetType) -> bool:
"""Check if .github/copilot-instructions.md should be compiled.

Only the Copilot-native targets (copilot/vscode/agents alias) and "all"
trigger generation. cursor, opencode, and codex use their own native
configuration files and must NOT receive copilot-instructions.md, even
when combined in a multi-target list.

Args:
target: The detected or configured target
target: The detected or configured target. May be a string or a
frozenset of compiler families for multi-target lists.

Returns:
bool: True if Copilot root instructions should be generated
"""
if isinstance(target, frozenset):
# "vscode" family is added to the frozenset by _resolve_compile_target()
# ONLY when copilot/vscode/agents was in the original list. Checking
# "agents" would over-fire because cursor/opencode/codex also map to
# the "agents" family for AGENTS.md generation.
return "vscode" in target
return target in ("vscode", "all")


Expand Down
Loading