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

## [Unreleased]

### Added

- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)

Comment thread
sergio-sisternes-epam marked this conversation as resolved.
### Fixed

- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ my-project/
Three things happened:

1. The package was downloaded into `apm_modules/` (like `node_modules/`).
2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, and `.opencode/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, and OpenCode read from.
2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, and `.opencode/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, and OpenCode read from. If the project has its own `.apm/` content, that is deployed too (local content takes priority over dependencies on collision).
3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration.

Your `apm.yml` now tracks the dependency:
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ apm install --only=apm
apm install --dry-run
```

`apm install` also deploys the project's own `.apm/` content (instructions, prompts, agents, skills, hooks, commands) to target directories alongside dependency content. Local content takes priority over dependencies on collision. This works even with zero dependencies -- just `apm.yml` and a `.apm/` directory is enough. See the [CLI reference](../../reference/cli-commands/#apm-install---install-dependencies-and-deploy-local-content) for details and exceptions.

### 3. Verify Installation

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/pack-distribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ The bundle includes a `plugin.json`. If one already exists in the project (at th

### devDependencies exclusion

Dependencies listed under [`devDependencies`](../../reference/manifest-schema/#5-devdependencies) in `apm.yml` are excluded from the plugin bundle. Use [`apm install --dev`](../../reference/cli-commands/#apm-install---install-apm-and-mcp-dependencies) to add dev deps:
Dependencies listed under [`devDependencies`](../../reference/manifest-schema/#5-devdependencies) in `apm.yml` are excluded from the plugin bundle. Use [`apm install --dev`](../../reference/cli-commands/#apm-install---install-dependencies-and-deploy-local-content) to add dev deps:

```bash
apm install --dev owner/test-helpers
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/guides/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ apm_modules/org/repo/my-package/
└── SKILL.md
```

The same promotion applies to the project's own `.apm/skills/` directory. When you run `apm install`, skills in your local `.apm/skills/*/` are deployed to `.github/skills/` (and other detected targets) alongside dependency skills. Local skills take priority on collision. The root `SKILL.md` is not treated as a local skill -- it describes the project itself.

## Package Detection

APM automatically detects package types:
Expand Down
17 changes: 13 additions & 4 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ apm init my-plugin --plugin
- `description` - Generated from project name
- `version` - Defaults to "1.0.0"

### `apm install` - Install APM and MCP dependencies
### `apm install` - Install dependencies and deploy local content

Install APM package and MCP server dependencies from `apm.yml` (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists.
Install APM package and MCP server dependencies from `apm.yml` and deploy the project's own `.apm/` content to target directories (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists.

```bash
apm install [PACKAGES...] [OPTIONS]
Expand All @@ -98,9 +98,18 @@ apm install [PACKAGES...] [OPTIONS]
- `-g, --global` - Install to user scope (`~/.apm/`) instead of the current project. Primitives deploy to `~/.copilot/`, `~/.claude/`, etc.

**Behavior:**
- `apm install` (no args): Installs **all** packages from `apm.yml`
- `apm install` (no args): Installs **all** packages from `apm.yml` and deploys the project's own `.apm/` content
- `apm install <package>`: Installs **only** the specified package (adds to `apm.yml` if not present)

**Local `.apm/` Content Deployment:**

After integrating dependencies, `apm install` deploys primitives from the project's own `.apm/` directory (instructions, prompts, agents, skills, hooks, commands) to target directories (`.github/`, `.claude/`, `.cursor/`, etc.). Local content takes priority over dependencies on collision. Deployed files are tracked in the lockfile for cleanup on subsequent installs. This works even with zero dependencies -- just `apm.yml` and `.apm/` content is enough.

Exceptions:
- Skipped at user scope (`--global`)
- Skipped with `--only=mcp`
- Root `SKILL.md` is not deployed as a local skill (it describes the project itself)

**Diff-Aware Installation (manifest as source of truth):**
- MCP servers already configured with matching config are skipped (`already configured`)
- MCP servers already configured but with changed manifest config are re-applied automatically (`updated`)
Expand Down Expand Up @@ -215,7 +224,7 @@ APM automatically detects which integrations to enable based on your project str

**VSCode Integration (`.github/` present):**

When you run `apm install`, APM automatically integrates primitives from installed packages:
When you run `apm install`, APM automatically integrates primitives from installed packages and the project's own `.apm/` directory:

- **Prompts**: `.prompt.md` files → `.github/prompts/*.prompt.md`
- **Agents**: `.agent.md` files → `.github/agents/*.agent.md`
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ devDependencies:
- owner/lint-rules#v2.0.0
```

Created automatically by `apm init --plugin`. Use [`apm install --dev`](../cli-commands/#apm-install---install-apm-and-mcp-dependencies) to add packages:
Created automatically by `apm init --plugin`. Use [`apm install --dev`](../cli-commands/#apm-install---install-dependencies-and-deploy-local-content) to add packages:

```bash
apm install --dev owner/test-helpers
Expand Down
1 change: 1 addition & 0 deletions packages/apm-guide/.apm/skills/apm-usage/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,6 @@ apm install # restores all deps from lockfile
```

The lockfile ensures every team member gets the exact same dependency versions.
`apm install` also deploys the project's own `.apm/` content (instructions, prompts, agents, skills, hooks, commands) to target directories alongside dependency content. Local content wins on collision. This works even with zero dependencies.
Subsequent `apm install` reads locked commit SHAs for reproducible installs.
Use `apm install --update` to refresh to latest refs.
240 changes: 240 additions & 0 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
# Display name for messages (short for project scope, full for user scope)
manifest_display = str(manifest_path) if scope is InstallScope.USER else APM_YML_FILENAME

# Project root for integration (used by both dep and local integration)
from ..core.scope import get_deploy_root
project_root = get_deploy_root(scope)

# Create shared auth resolver for all downloads in this CLI invocation
# to ensure credentials are cached and reused (prevents duplicate auth popups)
auth_resolver = AuthResolver()
Expand Down Expand Up @@ -785,11 +789,13 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
# field after the lockfile is regenerated by the APM install step.
old_mcp_servers: builtins.set = builtins.set()
old_mcp_configs: builtins.dict = {}
old_local_deployed: builtins.list = []
_lock_path = get_lockfile_path(apm_dir)
_existing_lock = LockFile.read(_lock_path)
if _existing_lock:
old_mcp_servers = builtins.set(_existing_lock.mcp_servers)
old_mcp_configs = builtins.dict(_existing_lock.mcp_configs)
old_local_deployed = builtins.list(_existing_lock.local_deployed_files)

apm_diagnostics = None
if should_install_apm and has_any_apm_deps:
Expand Down Expand Up @@ -876,6 +882,158 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
# mcp_servers. Restore the previous set so it is not lost.
MCPIntegrator.update_lockfile(old_mcp_servers, mcp_configs=old_mcp_configs)

# --- Local .apm/ content integration ---
# Deploy primitives from the project's own .apm/ folder to target
# directories, just like dependency primitives. Runs AFTER deps so
# local content wins on collision.
if (
should_install_apm
and scope is InstallScope.PROJECT
and not dry_run
and (_has_local_apm_content(project_root) or old_local_deployed)
):
try:
from apm_cli.integration.targets import resolve_targets as _local_resolve
from apm_cli.integration.skill_integrator import SkillIntegrator
from apm_cli.integration.command_integrator import CommandIntegrator
from apm_cli.integration.hook_integrator import HookIntegrator
from apm_cli.integration.instruction_integrator import InstructionIntegrator
from apm_cli.integration.base_integrator import BaseIntegrator
from apm_cli.deps.lockfile import LockFile as _LocalLF, get_lockfile_path as _local_lf_path
from apm_cli.integration import AgentIntegrator as _AgentInt, PromptIntegrator as _PromptInt

# Resolve targets (same precedence as _install_apm_dependencies)
_local_config_target = apm_package.target
_local_explicit = target or _local_config_target or None
_local_targets = _local_resolve(
project_root, user_scope=False, explicit_target=_local_explicit,
)

if _local_targets:
# Build managed_files: dep-deployed files + previous local
# deployed files. This ensures local content wins
# collisions with deps and previous local files are not
# treated as user-authored content.
_local_managed = builtins.set()
_local_lock_path = _local_lf_path(apm_dir)
_local_lock = _LocalLF.read(_local_lock_path)
if _local_lock:
for dep in _local_lock.dependencies.values():
_local_managed.update(dep.deployed_files)
# Include previous local deployed files so re-deploys
# overwrite rather than skip.
_local_managed.update(old_local_deployed)
_local_managed = BaseIntegrator.normalize_managed_files(_local_managed)

# Create integrators
_local_diagnostics = apm_diagnostics or DiagnosticCollector(verbose=verbose)
_errors_before_local = _local_diagnostics.error_count
_local_prompt_int = _PromptInt()
_local_agent_int = _AgentInt()
_local_skill_int = SkillIntegrator()
_local_instr_int = InstructionIntegrator()
_local_cmd_int = CommandIntegrator()
_local_hook_int = HookIntegrator()

logger.verbose_detail("Integrating local .apm/ content...")

local_int_result = _integrate_local_content(
project_root,
targets=_local_targets,
prompt_integrator=_local_prompt_int,
agent_integrator=_local_agent_int,
skill_integrator=_local_skill_int,
instruction_integrator=_local_instr_int,
command_integrator=_local_cmd_int,
hook_integrator=_local_hook_int,
force=force,
managed_files=_local_managed,
diagnostics=_local_diagnostics,
logger=logger,
scope=scope,
)

# Track what local integration deployed
_local_deployed = local_int_result.get("deployed_files", [])
_local_total = sum(
local_int_result.get(k, 0)
for k in ("prompts", "agents", "skills", "sub_skills",
"instructions", "commands", "hooks")
)

if _local_total > 0:
logger.verbose_detail(
f"Deployed {_local_total} local primitive(s) from .apm/"
)

# Stale cleanup: remove files deployed by previous local
# integration that are no longer produced. Only run when
# integration completed without errors to avoid deleting
# files that failed to re-deploy.
_errors_before = (
_local_diagnostics.error_count
if _local_diagnostics else 0
)
_local_had_errors = (
_local_diagnostics is not None
and _local_diagnostics.error_count > _errors_before_local
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
if old_local_deployed and not _local_had_errors:
_prev_local = builtins.set(old_local_deployed)
_curr_local = builtins.set(_local_deployed)
_stale = _prev_local - _curr_local
if _stale:
import shutil as _local_shutil
_stale_removed = 0
_stale_deleted_paths = []
_stale_failed = []
for _stale_path in sorted(_stale):
if BaseIntegrator.validate_deploy_path(
_stale_path, project_root, targets=_local_targets
):
_stale_target = project_root / _stale_path
if _stale_target.exists():
try:
if _stale_target.is_dir():
_local_shutil.rmtree(_stale_target)
else:
_stale_target.unlink()
_stale_deleted_paths.append(_stale_target)
_stale_removed += 1
except Exception:
_stale_failed.append(_stale_path)
logger.verbose_detail(
f"Failed to remove stale file: {_stale_path}"
)
# Keep failed paths in local_deployed so they
# are retried on the next install.
_local_deployed.extend(_stale_failed)
if _stale_deleted_paths:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
BaseIntegrator.cleanup_empty_parents(
_stale_deleted_paths, project_root
)
if _stale_removed > 0:
logger.verbose_detail(
f"Removed {_stale_removed} stale local file(s)"
)

# Persist local_deployed_files in the lockfile
_persist_lock = _LocalLF.read(_local_lock_path) or _LocalLF()
_persist_lock.local_deployed_files = sorted(_local_deployed)
# Only write if changed
_existing_for_cmp = _LocalLF.read(_local_lock_path)
if not _existing_for_cmp or not _persist_lock.is_semantically_equivalent(_existing_for_cmp):
_persist_lock.save(_local_lock_path)

# Ensure diagnostics flow into the final summary
if apm_diagnostics is None:
apm_diagnostics = _local_diagnostics

except Exception as e:
logger.verbose_detail(f"Local .apm/ integration failed: {e}")
if apm_diagnostics:
apm_diagnostics.error(f"Local .apm/ integration failed: {e}")

# Show diagnostics and final install summary
if apm_diagnostics and apm_diagnostics.has_diagnostics:
apm_diagnostics.render_summary()
Expand Down Expand Up @@ -1077,6 +1235,88 @@ def _log_integration(msg):
return result


def _has_local_apm_content(project_root):
"""Check if the project has local .apm/ content worth integrating.

Returns True if .apm/ exists and contains at least one primitive file
in a recognized subdirectory (skills, instructions, agents/chatmodes,
prompts, hooks, commands).
"""
apm_dir = project_root / ".apm"
if not apm_dir.is_dir():
return False
_PRIMITIVE_DIRS = ("skills", "instructions", "chatmodes", "agents", "prompts", "hooks", "commands")
for subdir_name in _PRIMITIVE_DIRS:
subdir = apm_dir / subdir_name
if subdir.is_dir() and any(p.is_file() for p in subdir.rglob("*")):
return True
return False


def _integrate_local_content(
project_root,
*,
targets,
prompt_integrator,
agent_integrator,
skill_integrator,
instruction_integrator,
command_integrator,
hook_integrator,
force,
managed_files,
diagnostics,
logger=None,
scope=None,
):
"""Integrate primitives from the project's own .apm/ directory.

This treats the project root as a synthetic package so that local
skills, instructions, agents, prompts, hooks, and commands in .apm/
are deployed to target directories exactly like dependency primitives.

Only .apm/ sub-directories are processed. A root-level SKILL.md is
intentionally ignored (it describes the project itself, not a
deployable skill).

Returns a dict with integration counters and deployed file paths,
same shape as ``_integrate_package_primitives()``.
"""
from ..models.apm_package import APMPackage, PackageInfo, PackageType

# Build a lightweight synthetic PackageInfo rooted at the project.
# package_type=APM_PACKAGE prevents SkillIntegrator from treating
# a root SKILL.md as a native skill to deploy.
local_pkg = APMPackage(
name="_local",
version="0.0.0",
package_path=project_root,
source="local",
)
local_info = PackageInfo(
package=local_pkg,
install_path=project_root,
package_type=PackageType.APM_PACKAGE,
)

return _integrate_package_primitives(
local_info,
project_root,
targets=targets,
prompt_integrator=prompt_integrator,
agent_integrator=agent_integrator,
skill_integrator=skill_integrator,
instruction_integrator=instruction_integrator,
command_integrator=command_integrator,
hook_integrator=hook_integrator,
force=force,
managed_files=managed_files,
diagnostics=diagnostics,
package_name="_local",
logger=logger,
scope=scope,
)


def _copy_local_package(dep_ref, install_path, project_root):
"""Copy a local package to apm_modules/.
Expand Down
Loading
Loading