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
2 changes: 1 addition & 1 deletion 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; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--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; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--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; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--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; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install; when the filter matches zero available skills a warning is printed listing what was requested and what is available), `--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
28 changes: 28 additions & 0 deletions src/apm_cli/integration/skill_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,20 @@ def _promote_sub_skills_standalone(
count = n
all_deployed.extend(deployed)

if name_filter is not None and count == 0:
from apm_cli.utils.console import _rich_warning

available = sorted(
d.name for d in sub_skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()
)
available_str = ", ".join(available) if available else "(none)"
requested_str = ", ".join(sorted(name_filter))
_rich_warning(
Comment on lines +902 to +904
f"--skill filter matched no skills in this package. "
f"Requested: {requested_str}. "
f"Available: {available_str}."
)

return count, all_deployed

def _integrate_native_skill(
Expand Down Expand Up @@ -1232,6 +1246,20 @@ def _integrate_skill_bundle(
any_created = True
all_deployed.extend(deployed)

if _name_filter is not None and total_promoted == 0:
from apm_cli.utils.console import _rich_warning

available = sorted(
d.name for d in skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()
)
available_str = ", ".join(available) if available else "(none)"
requested_str = ", ".join(sorted(_name_filter))
_rich_warning(
Comment on lines +1255 to +1257
f"--skill filter matched no skills in this package. "
f"Requested: {requested_str}. "
f"Available: {available_str}."
)

return SkillIntegrationResult(
skill_created=any_created,
skill_updated=False,
Expand Down
144 changes: 144 additions & 0 deletions tests/unit/integration/test_skill_integrator_hermetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,3 +950,147 @@ def test_promote_sub_skills_includes_bin_when_approved(self, tmp_path: Path) ->
assert (promoted_skill / "bin" / "helper").exists(), (
"bin/ must be included when skip_bin=False"
)


# ---------------------------------------------------------------------------
# _integrate_skill_bundle -- --skill filter matches nothing (issue #2116)
# ---------------------------------------------------------------------------


class TestIntegrateSkillBundleSubsetNoMatch:
"""--skill filter that matches zero skills emits a warning with available names."""

def test_no_match_warns_with_available_names(self, tmp_path: Path) -> None:
pkg_dir = tmp_path / "bundle-pkg"
pkg_dir.mkdir()
skills_dir = pkg_dir / "skills"
tdd_dir = skills_dir / "tdd"
tdd_dir.mkdir(parents=True)
(tdd_dir / "SKILL.md").write_text("# tdd skill", encoding="utf-8")

dep_ref = MagicMock()
dep_ref.is_virtual = False
dep_ref.get_unique_key.return_value = "owner/bundle-pkg"
pi = _make_package_info(pkg_dir, dep_ref=dep_ref)

target = _make_target(name="copilot", root_dir=".github")
integrator = SkillIntegrator()

with (
patch("apm_cli.utils.console._rich_warning") as mock_warn,
patch.object(SkillIntegrator, "_build_ownership_maps", return_value=({}, {})),
):
integrator._integrate_skill_bundle(
pi,
tmp_path,
skills_dir,
targets=[target],
skill_subset=("nonexistent",),
)

mock_warn.assert_called_once()
warning_msg = mock_warn.call_args[0][0]
assert "nonexistent" in warning_msg
assert "tdd" in warning_msg

def test_no_match_does_not_warn_when_no_filter(self, tmp_path: Path) -> None:
"""No warning when skill_subset is None (all skills are deployed)."""
pkg_dir = tmp_path / "bundle-pkg"
pkg_dir.mkdir()
skills_dir = pkg_dir / "skills"
tdd_dir = skills_dir / "tdd"
tdd_dir.mkdir(parents=True)
(tdd_dir / "SKILL.md").write_text("# tdd skill", encoding="utf-8")

dep_ref = MagicMock()
dep_ref.is_virtual = False
dep_ref.get_unique_key.return_value = "owner/bundle-pkg"
pi = _make_package_info(pkg_dir, dep_ref=dep_ref)

target = _make_target(name="copilot", root_dir=".github")
integrator = SkillIntegrator()

with (
patch("apm_cli.utils.console._rich_warning") as mock_warn,
patch.object(SkillIntegrator, "_build_ownership_maps", return_value=({}, {})),
):
integrator._integrate_skill_bundle(
pi,
tmp_path,
skills_dir,
targets=[target],
skill_subset=None,
)

mock_warn.assert_not_called()


# ---------------------------------------------------------------------------
# _promote_sub_skills_standalone -- --skill filter matches nothing (#2116)
# ---------------------------------------------------------------------------


class TestPromoteSubSkillsStandaloneSubsetNoMatch:
"""--skill filter that matches zero .apm/skills/ entries emits a warning."""

def test_no_match_warns_with_available_names(self, tmp_path: Path) -> None:
pkg_dir = tmp_path / "instr-pkg"
pkg_dir.mkdir()
sub_skills_dir = pkg_dir / ".apm" / "skills"
tdd_dir = sub_skills_dir / "tdd"
tdd_dir.mkdir(parents=True)
(tdd_dir / "SKILL.md").write_text("# tdd skill", encoding="utf-8")

dep_ref = MagicMock()
dep_ref.is_virtual = False
dep_ref.get_unique_key.return_value = "owner/instr-pkg"
pi = _make_package_info(pkg_dir, dep_ref=dep_ref)

target = _make_target(name="copilot", root_dir=".github")
integrator = SkillIntegrator()

with (
patch("apm_cli.utils.console._rich_warning") as mock_warn,
patch.object(SkillIntegrator, "_build_skill_ownership_map", return_value={}),
):
integrator._promote_sub_skills_standalone(
pi,
tmp_path,
targets=[target],
skill_subset=("nonexistent",),
)

mock_warn.assert_called_once()
warning_msg = mock_warn.call_args[0][0]
assert "nonexistent" in warning_msg
assert "tdd" in warning_msg

def test_no_match_does_not_warn_when_no_filter(self, tmp_path: Path) -> None:
"""No warning when skill_subset is None."""
pkg_dir = tmp_path / "instr-pkg"
pkg_dir.mkdir()
sub_skills_dir = pkg_dir / ".apm" / "skills"
tdd_dir = sub_skills_dir / "tdd"
tdd_dir.mkdir(parents=True)
(tdd_dir / "SKILL.md").write_text("# tdd skill", encoding="utf-8")

dep_ref = MagicMock()
dep_ref.is_virtual = False
dep_ref.get_unique_key.return_value = "owner/instr-pkg"
pi = _make_package_info(pkg_dir, dep_ref=dep_ref)

target = _make_target(name="copilot", root_dir=".github")
integrator = SkillIntegrator()

with (
patch("apm_cli.utils.console._rich_warning") as mock_warn,
patch.object(SkillIntegrator, "_build_skill_ownership_map", return_value={}),
):
integrator._promote_sub_skills_standalone(
pi,
tmp_path,
targets=[target],
skill_subset=None,
)

mock_warn.assert_not_called()
Loading