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
11 changes: 6 additions & 5 deletions docs/src/content/docs/getting-started/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ apm install
```

The `--skill` flag is repeatable. Your selection is written to `apm.yml` and
`apm.lock.yaml` so the exact subset is reproducible on every machine.
`apm.lock.yaml` so the exact subset is reproducible on every machine. For
plugin manifest collections, pass either the skill name or the manifest path.

```bash
# Pick two skills, then reset to all
Expand Down Expand Up @@ -105,7 +106,7 @@ skill collection layout reference.
:::caution[Behavior change]
Skills for **Copilot, Cursor, OpenCode, Codex, and Gemini** now deploy to `.agents/skills/` by default instead of per-client directories (`.github/skills/`, `.cursor/skills/`, `.gemini/skills/`, etc.). This matches the `.agents/` discovery path documented by all five clients and eliminates redundant copies when targeting multiple clients.

**Claude is unchanged** its skills continue to deploy to `.claude/skills/`.
**Claude is unchanged** - its skills continue to deploy to `.claude/skills/`.

To restore the previous per-client layout, pass `--legacy-skill-paths` to any command, or set the `APM_LEGACY_SKILL_PATHS=1` environment variable.
:::
Expand All @@ -117,12 +118,12 @@ When you upgrade APM and run `apm install`, the tool automatically detects legac
**What happens:**
- Old per-client skill files are deleted after the new `.agents/skills/` files are written
- The lockfile is updated to reflect the new paths
- The migration is idempotent running `apm install` again is a no-op
- The migration is idempotent - running `apm install` again is a no-op
- Foreign / hand-authored skills outside the lockfile are never touched

**What does NOT migrate:**
- `.claude/skills/` Claude is not part of the convergence
- `.codex/skills/` Codex was already on `.agents/skills/` before this change
- `.claude/skills/` - Claude is not part of the convergence
- `.codex/skills/` - Codex was already on `.agents/skills/` before this change
- Any file not tracked in `apm.lock.yaml`

**If a collision is detected** (e.g., a foreign file already exists at the destination `.agents/skills/` path with different content), the migration aborts entirely with a clear error. Use `--legacy-skill-paths` to skip migration and keep per-client paths.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initi

| Flag | Default | Description |
|---|---|---|
| `--skill NAME` | all | Install only named skill(s) from a `SKILL_BUNDLE` package. Repeatable. The selection is persisted to `apm.yml` and `apm.lock.yaml`. Use `--skill '*'` to reset and install all. |
| `--skill NAME` | all | Install only named skill(s) from a skill collection (`SKILL_BUNDLE` or plugin manifest). Repeatable. For plugin manifests, `NAME` may be the skill name or manifest path, such as `skills/productivity/grill-me`. The selection is persisted to `apm.yml` and `apm.lock.yaml`. Use `--skill '*'` to reset and install all. |
| `--as ALIAS` | bundle id | Override the log/display label for a local-bundle install. Only valid with a single local-bundle `PACKAGE_REF`. |

### MCP server entry (use only with `--mcp`)
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/package-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ my-plugin/
```

**What gets installed:** each artifact listed in `plugin.json` is mapped to
the appropriate runtime directory via `_map_plugin_artifacts`.
the appropriate runtime directory via `_map_plugin_artifacts`. Use `--skill`
to cherry-pick plugin skills by leaf name or manifest path, such as
`skills/productivity/grill-me`.

**When to choose:** you already have a Claude plugin and want APM to
consume it without restructuring.
Expand Down
4 changes: 2 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Codex CLI, JetBrains Copilot), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) |
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Codex CLI, JetBrains Copilot), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) |
| `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). |
| `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global |
| `apm prune` | Remove orphaned packages | `--dry-run` |
Expand Down Expand Up @@ -91,7 +91,7 @@ Behind `apm experimental enable registries`. Pushes a package version to a REST-

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm publish` | Auto-pack a flat registry zip archive (`apm.yml` + `.apm/` + `README.md`/`CHANGELOG.md`/`LICENSE` when present) and upload to a configured registry via `PUT /v1/packages/{owner}/{repo}/versions/{version}`. Different layout from `apm pack` (no `plugin.json` wrapper). | `--registry NAME` (required when multiple registries are configured), `--package OWNER/REPO` (**required** registry package identity, e.g. `acme/my-skill`), `--zip PATH` (skip auto-pack and upload a pre-built `.zip`), `--dry-run`, `-v`/`--verbose` |
| `apm publish` | Auto-pack a flat registry zip archive (`apm.yml` + `.apm/` + `README.md`/`CHANGELOG.md`/`LICENSE` when present) and upload to a configured registry via `PUT /v1/packages/{owner}/{repo}/versions/{version}`. Different layout from `apm pack` (no `plugin.json` wrapper). | `--registry NAME` (required when multiple registries are configured), `--package OWNER/REPO` (**required** - registry package identity, e.g. `acme/my-skill`), `--zip PATH` (skip auto-pack and upload a pre-built `.zip`), `--dry-run`, `-v`/`--verbose` |

Examples:

Expand Down
40 changes: 33 additions & 7 deletions src/apm_cli/integration/skill_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,25 @@ def _resolve_markdown_links_in_skill_bundle(
links_resolved += count
return links_resolved

@staticmethod
def _skill_subset_name_filter(skill_subset: tuple[str, ...] | None) -> set[str] | None:
"""Return promotion filter tokens for --skill subset values."""
if not skill_subset:
return None

name_filter: set[str] = set()
for skill_name in skill_subset:
raw_name = str(skill_name).strip()
if not raw_name:
continue
normalized_path = raw_name.replace("\\", "/")
leaf_name = Path(normalized_path).name
name_filter.add(raw_name)
name_filter.add(normalized_path)
if leaf_name:
name_filter.add(leaf_name)
return name_filter or None

@staticmethod
def _promote_sub_skills(
sub_skills_dir: Path,
Expand All @@ -568,7 +587,7 @@ def _promote_sub_skills(
force: bool = False,
project_root: Path | None = None,
logger=None,
name_filter: "set | None" = None,
name_filter: set[str] | None = None,
link_rewriter: "SkillIntegrator | None" = None,
) -> tuple[int, list[Path]]:
"""Promote sub-skills from .apm/skills/ to top-level skill entries.
Expand Down Expand Up @@ -616,7 +635,7 @@ def _promote_sub_skills(
target = target_skills_root / sub_name
rel_path = f"{rel_prefix}/{sub_name}"
if target.exists():
# Content-identical skip entirely (no copy, no warning)
# Content-identical: skip entirely (no copy, no warning)
if SkillIntegrator.is_skill_dir_identical_to_source(sub_skill_path, target):
promoted += 1
deployed.append(target)
Expand All @@ -630,7 +649,7 @@ def _promote_sub_skills(
is_self_overwrite = prev_owner is not None and prev_owner == parent_name

if managed_files is not None and not is_managed and not is_self_overwrite:
# User-authored skill respect force flag
# User-authored skill: respect force flag
if not force:
if diagnostics is not None:
diagnostics.skip(rel_path, package=parent_name)
Expand All @@ -649,7 +668,7 @@ def _promote_sub_skills(
)
except ImportError:
pass
continue # SKIP protect user content
continue # SKIP: protect user content

if warn and not is_self_overwrite:
if diagnostics is not None:
Expand Down Expand Up @@ -741,6 +760,7 @@ def _promote_sub_skills_standalone(
force: bool = False,
logger=None,
targets=None,
skill_subset=None,
) -> tuple[int, list[Path]]:
"""Promote sub-skills from a package that is NOT itself a skill.

Expand All @@ -753,6 +773,7 @@ def _promote_sub_skills_standalone(
package_info: PackageInfo object with package metadata.
project_root: Root directory of the project.
targets: Optional explicit list of TargetProfile objects.
skill_subset: Optional tuple of skill names or paths to install (None = all).

Returns:
tuple[int, list[Path]]: (count of promoted sub-skills, list of deployed dirs)
Expand All @@ -770,6 +791,7 @@ def _promote_sub_skills_standalone(

parent_name = package_path.name
owned_by = self._build_skill_ownership_map(project_root)
name_filter = self._skill_subset_name_filter(skill_subset)
count = 0
all_deployed: list[Path] = []
seen_skill_dirs: set[Path] = set()
Expand Down Expand Up @@ -810,6 +832,7 @@ def _promote_sub_skills_standalone(
managed_files=managed_files if is_primary else None,
force=force,
project_root=project_root,
name_filter=name_filter,
link_rewriter=self,
)
if is_primary:
Expand Down Expand Up @@ -1104,8 +1127,8 @@ def _integrate_skill_bundle(
any_created = False
seen_skill_dirs: set[Path] = set()

# Convert skill_subset tuple to a set for O(1) lookup
_name_filter = set(skill_subset) if skill_subset else None
# Convert skill_subset tuple to promotion filter tokens for O(1) lookup.
_name_filter = self._skill_subset_name_filter(skill_subset)

for idx, target in enumerate(targets):
if not target.supports("skills"):
Expand Down Expand Up @@ -1189,6 +1212,7 @@ def integrate_package_skill(
package_info: PackageInfo object with package metadata
project_root: Root directory of the project
targets: Optional explicit list of TargetProfile objects.
skill_subset: Optional tuple of skill names or paths to install (None = all).

Returns:
SkillIntegrationResult: Results of the integration operation
Expand All @@ -1207,6 +1231,7 @@ def integrate_package_skill(
force=force,
logger=logger,
targets=targets,
skill_subset=skill_subset,
)
return SkillIntegrationResult(
skill_created=False,
Expand Down Expand Up @@ -1311,6 +1336,7 @@ def integrate_package_skill(
force=force,
logger=logger,
targets=targets,
skill_subset=skill_subset,
)
return self._merge_bin_paths(
SkillIntegrationResult(
Expand Down Expand Up @@ -1637,7 +1663,7 @@ def sync_integration(
if ".." in rel_path:
continue

# ── Cowork:// paths ──────────────────────────────────
# Cowork:// paths
from apm_cli.integration.copilot_cowork_paths import COWORK_URI_SCHEME

if rel_path.startswith(COWORK_URI_SCHEME):
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/integration/test_skill_integrator_phase3w4.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,46 @@ def test_overwrite_warning_with_logger(self, tmp_path: Path) -> None:
class TestPromoteSubSkillsStandaloneDedup:
"""Second target with identical resolved path is logged+skipped."""

def test_plugin_manifest_subset_promotes_only_selected_nested_skill(
self, tmp_path: Path
) -> None:
"""--skill path filters plugin-manifest sub-skills during promotion."""
from apm_cli.deps.plugin_parser import _map_plugin_artifacts
from apm_cli.models.apm_package import PackageType

plugin_root = tmp_path / "skills-repo"
selected_skill = plugin_root / "skills" / "productivity" / "grill-me"
other_skill = plugin_root / "skills" / "writing" / "summarize-me"
selected_skill.mkdir(parents=True)
other_skill.mkdir(parents=True)
(selected_skill / "SKILL.md").write_text("# Grill Me", encoding="utf-8")
(other_skill / "SKILL.md").write_text("# Summarize Me", encoding="utf-8")

manifest = {
"name": "skills-repo",
"skills": [
"skills/productivity/grill-me",
"skills/writing/summarize-me",
],
}
_map_plugin_artifacts(plugin_root, plugin_root / ".apm", manifest)

pkg_info = _make_package_info(plugin_root, package_type=PackageType.MARKETPLACE_PLUGIN)
target = _make_target(name="claude", root_dir=".claude", auto_create=True)
project_root = tmp_path / "project"
project_root.mkdir()

result = SkillIntegrator().integrate_package_skill(
pkg_info,
project_root,
targets=[target],
skill_subset=("skills/productivity/grill-me",),
)

assert result.sub_skills_promoted == 1
assert (project_root / ".claude" / "skills" / "grill-me" / "SKILL.md").exists()
assert not (project_root / ".claude" / "skills" / "summarize-me" / "SKILL.md").exists()

def test_duplicate_target_skips_with_logger(self, tmp_path: Path) -> None:
pkg_dir = tmp_path / "pkg"
sub_skills = pkg_dir / ".apm" / "skills"
Expand Down
Loading