Skip to content

Commit 1cf9acb

Browse files
Merge pull request #644 from sergio-sisternes-epam/feat/626-local-apm-content-install
feat: support project-local .apm/ content during apm install
2 parents b56920e + 6908a35 commit 1cf9acb

File tree

12 files changed

+1630
-1084
lines changed

12 files changed

+1630
-1084
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- `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)
14+
1115
### Fixed
1216

1317
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)

docs/src/content/docs/getting-started/quick-start.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ my-project/
9797
Three things happened:
9898

9999
1. The package was downloaded into `apm_modules/` (like `node_modules/`).
100-
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.
100+
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).
101101
3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration.
102102

103103
Your `apm.yml` now tracks the dependency:

docs/src/content/docs/guides/dependencies.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ apm install --only=apm
204204
apm install --dry-run
205205
```
206206

207+
`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.
208+
207209
### 3. Verify Installation
208210

209211
```bash

docs/src/content/docs/guides/pack-distribute.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ The bundle includes a `plugin.json`. If one already exists in the project (at th
214214

215215
### devDependencies exclusion
216216

217-
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:
217+
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:
218218

219219
```bash
220220
apm install --dev owner/test-helpers

docs/src/content/docs/guides/skills.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ apm_modules/org/repo/my-package/
274274
└── SKILL.md
275275
```
276276

277+
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.
278+
277279
## Package Detection
278280

279281
APM automatically detects package types:

docs/src/content/docs/reference/cli-commands.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ apm init my-plugin --plugin
7272
- `description` - Generated from project name
7373
- `version` - Defaults to "1.0.0"
7474

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

77-
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.
77+
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.
7878

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

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

104+
**Local `.apm/` Content Deployment:**
105+
106+
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.
107+
108+
Exceptions:
109+
- Skipped at user scope (`--global`)
110+
- Skipped with `--only=mcp`
111+
- Root `SKILL.md` is not deployed as a local skill (it describes the project itself)
112+
104113
**Diff-Aware Installation (manifest as source of truth):**
105114
- MCP servers already configured with matching config are skipped (`already configured`)
106115
- MCP servers already configured but with changed manifest config are re-applied automatically (`updated`)
@@ -215,7 +224,7 @@ APM automatically detects which integrations to enable based on your project str
215224

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

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

220229
- **Prompts**: `.prompt.md` files → `.github/prompts/*.prompt.md`
221230
- **Agents**: `.agent.md` files → `.github/agents/*.agent.md`

docs/src/content/docs/reference/manifest-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ devDependencies:
365365
- owner/lint-rules#v2.0.0
366366
```
367367

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

370370
```bash
371371
apm install --dev owner/test-helpers

packages/apm-guide/.apm/skills/apm-usage/workflow.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,6 @@ apm install # restores all deps from lockfile
8585
```
8686

8787
The lockfile ensures every team member gets the exact same dependency versions.
88+
`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.
8889
Subsequent `apm install` reads locked commit SHAs for reproducible installs.
8990
Use `apm install --update` to refresh to latest refs.

src/apm_cli/commands/install.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
668668
# Display name for messages (short for project scope, full for user scope)
669669
manifest_display = str(manifest_path) if scope is InstallScope.USER else APM_YML_FILENAME
670670

671+
# Project root for integration (used by both dep and local integration)
672+
from ..core.scope import get_deploy_root
673+
project_root = get_deploy_root(scope)
674+
671675
# Create shared auth resolver for all downloads in this CLI invocation
672676
# to ensure credentials are cached and reused (prevents duplicate auth popups)
673677
auth_resolver = AuthResolver()
@@ -785,11 +789,13 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
785789
# field after the lockfile is regenerated by the APM install step.
786790
old_mcp_servers: builtins.set = builtins.set()
787791
old_mcp_configs: builtins.dict = {}
792+
old_local_deployed: builtins.list = []
788793
_lock_path = get_lockfile_path(apm_dir)
789794
_existing_lock = LockFile.read(_lock_path)
790795
if _existing_lock:
791796
old_mcp_servers = builtins.set(_existing_lock.mcp_servers)
792797
old_mcp_configs = builtins.dict(_existing_lock.mcp_configs)
798+
old_local_deployed = builtins.list(_existing_lock.local_deployed_files)
793799

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

885+
# --- Local .apm/ content integration ---
886+
# Deploy primitives from the project's own .apm/ folder to target
887+
# directories, just like dependency primitives. Runs AFTER deps so
888+
# local content wins on collision.
889+
if (
890+
should_install_apm
891+
and scope is InstallScope.PROJECT
892+
and not dry_run
893+
and (_has_local_apm_content(project_root) or old_local_deployed)
894+
):
895+
try:
896+
from apm_cli.integration.targets import resolve_targets as _local_resolve
897+
from apm_cli.integration.skill_integrator import SkillIntegrator
898+
from apm_cli.integration.command_integrator import CommandIntegrator
899+
from apm_cli.integration.hook_integrator import HookIntegrator
900+
from apm_cli.integration.instruction_integrator import InstructionIntegrator
901+
from apm_cli.integration.base_integrator import BaseIntegrator
902+
from apm_cli.deps.lockfile import LockFile as _LocalLF, get_lockfile_path as _local_lf_path
903+
from apm_cli.integration import AgentIntegrator as _AgentInt, PromptIntegrator as _PromptInt
904+
905+
# Resolve targets (same precedence as _install_apm_dependencies)
906+
_local_config_target = apm_package.target
907+
_local_explicit = target or _local_config_target or None
908+
_local_targets = _local_resolve(
909+
project_root, user_scope=False, explicit_target=_local_explicit,
910+
)
911+
912+
if _local_targets:
913+
# Build managed_files: dep-deployed files + previous local
914+
# deployed files. This ensures local content wins
915+
# collisions with deps and previous local files are not
916+
# treated as user-authored content.
917+
_local_managed = builtins.set()
918+
_local_lock_path = _local_lf_path(apm_dir)
919+
_local_lock = _LocalLF.read(_local_lock_path)
920+
if _local_lock:
921+
for dep in _local_lock.dependencies.values():
922+
_local_managed.update(dep.deployed_files)
923+
# Include previous local deployed files so re-deploys
924+
# overwrite rather than skip.
925+
_local_managed.update(old_local_deployed)
926+
_local_managed = BaseIntegrator.normalize_managed_files(_local_managed)
927+
928+
# Create integrators
929+
_local_diagnostics = apm_diagnostics or DiagnosticCollector(verbose=verbose)
930+
_errors_before_local = _local_diagnostics.error_count
931+
_local_prompt_int = _PromptInt()
932+
_local_agent_int = _AgentInt()
933+
_local_skill_int = SkillIntegrator()
934+
_local_instr_int = InstructionIntegrator()
935+
_local_cmd_int = CommandIntegrator()
936+
_local_hook_int = HookIntegrator()
937+
938+
logger.verbose_detail("Integrating local .apm/ content...")
939+
940+
local_int_result = _integrate_local_content(
941+
project_root,
942+
targets=_local_targets,
943+
prompt_integrator=_local_prompt_int,
944+
agent_integrator=_local_agent_int,
945+
skill_integrator=_local_skill_int,
946+
instruction_integrator=_local_instr_int,
947+
command_integrator=_local_cmd_int,
948+
hook_integrator=_local_hook_int,
949+
force=force,
950+
managed_files=_local_managed,
951+
diagnostics=_local_diagnostics,
952+
logger=logger,
953+
scope=scope,
954+
)
955+
956+
# Track what local integration deployed
957+
_local_deployed = local_int_result.get("deployed_files", [])
958+
_local_total = sum(
959+
local_int_result.get(k, 0)
960+
for k in ("prompts", "agents", "skills", "sub_skills",
961+
"instructions", "commands", "hooks")
962+
)
963+
964+
if _local_total > 0:
965+
logger.verbose_detail(
966+
f"Deployed {_local_total} local primitive(s) from .apm/"
967+
)
968+
969+
# Stale cleanup: remove files deployed by previous local
970+
# integration that are no longer produced. Only run when
971+
# integration completed without errors to avoid deleting
972+
# files that failed to re-deploy.
973+
_errors_before = (
974+
_local_diagnostics.error_count
975+
if _local_diagnostics else 0
976+
)
977+
_local_had_errors = (
978+
_local_diagnostics is not None
979+
and _local_diagnostics.error_count > _errors_before_local
980+
)
981+
if old_local_deployed and not _local_had_errors:
982+
_prev_local = builtins.set(old_local_deployed)
983+
_curr_local = builtins.set(_local_deployed)
984+
_stale = _prev_local - _curr_local
985+
if _stale:
986+
import shutil as _local_shutil
987+
_stale_removed = 0
988+
_stale_deleted_paths = []
989+
_stale_failed = []
990+
for _stale_path in sorted(_stale):
991+
if BaseIntegrator.validate_deploy_path(
992+
_stale_path, project_root, targets=_local_targets
993+
):
994+
_stale_target = project_root / _stale_path
995+
if _stale_target.exists():
996+
try:
997+
if _stale_target.is_dir():
998+
_local_shutil.rmtree(_stale_target)
999+
else:
1000+
_stale_target.unlink()
1001+
_stale_deleted_paths.append(_stale_target)
1002+
_stale_removed += 1
1003+
except Exception:
1004+
_stale_failed.append(_stale_path)
1005+
logger.verbose_detail(
1006+
f"Failed to remove stale file: {_stale_path}"
1007+
)
1008+
# Keep failed paths in local_deployed so they
1009+
# are retried on the next install.
1010+
_local_deployed.extend(_stale_failed)
1011+
if _stale_deleted_paths:
1012+
BaseIntegrator.cleanup_empty_parents(
1013+
_stale_deleted_paths, project_root
1014+
)
1015+
if _stale_removed > 0:
1016+
logger.verbose_detail(
1017+
f"Removed {_stale_removed} stale local file(s)"
1018+
)
1019+
1020+
# Persist local_deployed_files in the lockfile
1021+
_persist_lock = _LocalLF.read(_local_lock_path) or _LocalLF()
1022+
_persist_lock.local_deployed_files = sorted(_local_deployed)
1023+
# Only write if changed
1024+
_existing_for_cmp = _LocalLF.read(_local_lock_path)
1025+
if not _existing_for_cmp or not _persist_lock.is_semantically_equivalent(_existing_for_cmp):
1026+
_persist_lock.save(_local_lock_path)
1027+
1028+
# Ensure diagnostics flow into the final summary
1029+
if apm_diagnostics is None:
1030+
apm_diagnostics = _local_diagnostics
1031+
1032+
except Exception as e:
1033+
logger.verbose_detail(f"Local .apm/ integration failed: {e}")
1034+
if apm_diagnostics:
1035+
apm_diagnostics.error(f"Local .apm/ integration failed: {e}")
1036+
8791037
# Show diagnostics and final install summary
8801038
if apm_diagnostics and apm_diagnostics.has_diagnostics:
8811039
apm_diagnostics.render_summary()
@@ -1077,6 +1235,88 @@ def _log_integration(msg):
10771235
return result
10781236

10791237

1238+
def _has_local_apm_content(project_root):
1239+
"""Check if the project has local .apm/ content worth integrating.
1240+
1241+
Returns True if .apm/ exists and contains at least one primitive file
1242+
in a recognized subdirectory (skills, instructions, agents/chatmodes,
1243+
prompts, hooks, commands).
1244+
"""
1245+
apm_dir = project_root / ".apm"
1246+
if not apm_dir.is_dir():
1247+
return False
1248+
_PRIMITIVE_DIRS = ("skills", "instructions", "chatmodes", "agents", "prompts", "hooks", "commands")
1249+
for subdir_name in _PRIMITIVE_DIRS:
1250+
subdir = apm_dir / subdir_name
1251+
if subdir.is_dir() and any(p.is_file() for p in subdir.rglob("*")):
1252+
return True
1253+
return False
1254+
1255+
1256+
def _integrate_local_content(
1257+
project_root,
1258+
*,
1259+
targets,
1260+
prompt_integrator,
1261+
agent_integrator,
1262+
skill_integrator,
1263+
instruction_integrator,
1264+
command_integrator,
1265+
hook_integrator,
1266+
force,
1267+
managed_files,
1268+
diagnostics,
1269+
logger=None,
1270+
scope=None,
1271+
):
1272+
"""Integrate primitives from the project's own .apm/ directory.
1273+
1274+
This treats the project root as a synthetic package so that local
1275+
skills, instructions, agents, prompts, hooks, and commands in .apm/
1276+
are deployed to target directories exactly like dependency primitives.
1277+
1278+
Only .apm/ sub-directories are processed. A root-level SKILL.md is
1279+
intentionally ignored (it describes the project itself, not a
1280+
deployable skill).
1281+
1282+
Returns a dict with integration counters and deployed file paths,
1283+
same shape as ``_integrate_package_primitives()``.
1284+
"""
1285+
from ..models.apm_package import APMPackage, PackageInfo, PackageType
1286+
1287+
# Build a lightweight synthetic PackageInfo rooted at the project.
1288+
# package_type=APM_PACKAGE prevents SkillIntegrator from treating
1289+
# a root SKILL.md as a native skill to deploy.
1290+
local_pkg = APMPackage(
1291+
name="_local",
1292+
version="0.0.0",
1293+
package_path=project_root,
1294+
source="local",
1295+
)
1296+
local_info = PackageInfo(
1297+
package=local_pkg,
1298+
install_path=project_root,
1299+
package_type=PackageType.APM_PACKAGE,
1300+
)
1301+
1302+
return _integrate_package_primitives(
1303+
local_info,
1304+
project_root,
1305+
targets=targets,
1306+
prompt_integrator=prompt_integrator,
1307+
agent_integrator=agent_integrator,
1308+
skill_integrator=skill_integrator,
1309+
instruction_integrator=instruction_integrator,
1310+
command_integrator=command_integrator,
1311+
hook_integrator=hook_integrator,
1312+
force=force,
1313+
managed_files=managed_files,
1314+
diagnostics=diagnostics,
1315+
package_name="_local",
1316+
logger=logger,
1317+
scope=scope,
1318+
)
1319+
10801320

10811321
def _copy_local_package(dep_ref, install_path, project_root):
10821322
"""Copy a local package to apm_modules/.

0 commit comments

Comments
 (0)