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

### Changed

- **BREAKING:** Non-dry-run `apm install` now exits `1` when Agent Plugins v1 target exclusion leaves no package deployed; mixed installs that deploy another package still succeed. Install a direct skill subpath (`apm install kunchenguid/lavish-axi/skills/lavish#main --target codex`) or select `--target copilot` for native registration. (closes #2796) (#2806)
- **BREAKING:** after consumers re-vendor the shared gh-aw `apm.md`, its import requires an explicit `target` instead of deprecated `all`; `apm-action` otherwise writes `all` into the isolated `apm.yml`, where it degrades to auto-detection without harness markers. Set the workflow engine's target and recompile; see the [gh-aw migration recipe](https://microsoft.github.io/apm/integrations/gh-aw/#shared-apmmd-import-recommended). (#2706)
- Re-vendored shared gh-aw workflows now default to APM 0.28.0 for both pack and restore, the version used for the recorded `microsoft/apm-action@v1.10.0` compatibility proof, not the latest CLI release; an explicit `apm-version` still overrides it. (#2706)

Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,14 @@ apm install owner/skill-bundle --skill '*' # reset to all skills

| Code | Meaning |
|---|---|
| `0` | Success. All requested dependencies and local content deployed. |
| `1` | Install failure: security scan blocked a critical finding, auth error, manifest or required MCP/LSP config write error, dependency resolution error, `--frozen` with a missing lockfile or a direct dependency absent from `apm.lock.yaml`, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. |
| `0` | Successful install or `--dry-run` preview. A preview does not certify real install success. For Agent Plugins v1 packages, a mixed install still succeeds when target exclusion skips one package but at least one other package deploys. |
| `1` | Install failure: security scan blocked a critical finding, auth error, manifest or required MCP/LSP config write error, dependency resolution error, Agent Plugins v1 target exclusion left no package deployed on a non-dry-run install, `--frozen` with a missing lockfile or a direct dependency absent from `apm.lock.yaml`, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. |
| `2` | Usage error: no deployment target detectable (no `--target`, no `target(s):` in `apm.yml`, no default target configured via `apm config set target <value>`, and no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, `--root` combined with `--global`, or a Click flag conflict. |

## Notes

- **`--force` is dual-purpose.** It overwrites locally-authored files on collision **and** disables the critical-finding block from the built-in security scan. It does **not** suppress general install errors -- any error reported in the diagnostic summary still exits `1` (matches `npm` / `pip` / `cargo`). It does **not** refresh remote refs -- for routine ref updates, run [`apm update`](../update/). To remediate a blocked package, fix the reported source files and reinstall; `apm audit --strip` only remediates files that are already deployed. See [Drift and secure by default](../../../consumer/drift-and-secure-by-default/).
- **Agent Plugin target exclusion is fail-loud on non-dry-run total no-ops.** Agent Plugins v1 packages register natively only with the effective `copilot` target today. If every selected target excludes native registration and no other package deploys, a non-dry-run install exits `1` and prints a skill-subpath recovery command such as `apm install kunchenguid/lavish-axi/skills/lavish#main --target codex`. Keep refs after the skill path to install a plain skill bundle, or select `--target copilot` for native registration. Mixed installs that deploy at least one other package still exit `0`. For this target exclusion, `--dry-run` remains a successful preview without the per-package recovery diagnostic; it does not certify that a real install will succeed.
- **Target contraction is reconciled.** A narrowed `targets:` in `apm.yml` is reconciled on the next non-dry-run install: deployed files, lockfile ownership, and merge-hook config/sidecar entries for the dropped target are cleaned up, even when no dependency itself changed. A package's own `target:` / `targets:` declaration applies an additional restriction within that effective set. See [Hooks and commands](../../../producer/author-primitives/hooks-and-commands/#hooks) for the full intersection and merge-hook config/sidecar details. `apm lock` may refresh the lockfile rows, but it never deletes deployed files from disk.
- **Claude target prompt rewrite.** When deploying to `.claude/commands/`, prompt files with an `input:` front-matter key are rewritten to Claude's `arguments:` shape and `${input:name}` placeholders become `$name`. Argument names must match `^[A-Za-z][\w-]{0,63}$`; rejected names are dropped with a warning.
- **MCP env-var passthrough.** Copilot CLI and Kiro translate `${env:VAR}` and `<VAR>` to `${VAR}` in their MCP configs. Kiro writes `.kiro/settings/mcp.json` and `~/.kiro/settings/mcp.json` with `0o600` permissions. JetBrains Copilot preserves env references as `${env:VAR}` in `github-copilot/intellij/mcp.json`. Plaintext secrets are never written to disk for these runtime-resolved targets; legacy targets resolve placeholders at install time.
Expand Down
11 changes: 8 additions & 3 deletions docs/src/content/docs/reference/package-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,14 @@ Any target set that excludes Copilot refuses this native install rather than
falling back to the Claude plugin artifact mapping above -- APM never partially
dissects a recognized Agent Plugin through its normal primitive integrators.
Older Copilot clients may copy plugins into private state outside APM ownership,
so APM cannot guarantee cleanup of those client-created copies. Ask the
publisher for a Claude-compatible package (`apm pack --claude-plugin`) for a
non-Copilot target.
so APM cannot guarantee cleanup of those client-created copies.

On non-dry-run installs, target exclusion exits `1` only when no package
deploys; mixed installs that deploy another package still exit `0`.
`--dry-run` remains a successful preview, not proof of real install success.
See [`apm install` notes](../cli/install/#notes) for recovery commands and
dry-run diagnostic behavior. Publishers can also provide a Claude-compatible
package (`apm pack --claude-plugin`) for non-Copilot targets.
:::

**When to choose:** you are producing a portable package with
Expand Down
8 changes: 8 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ writes only `.vscode/mcp.json`.

`apm compile` continues to use legacy auto-detection with a `vscode`/`minimal` fallback for unsignalled projects -- bringing it onto the strict resolution chain is tracked as a follow-up.

Agent Plugins v1 packages register natively only with the effective `copilot`
target today. If target exclusion leaves a non-dry-run install with no deployed package,
`apm install` exits `1` and prints a recovery command such as
`apm install kunchenguid/lavish-axi/skills/lavish#main --target codex`; mixed
installs that deploy at least one other package still exit `0`. For this target
exclusion, `--dry-run` succeeds as a preview without the per-package recovery
diagnostic; it does not certify real install success.

## Cache management

| Command | Purpose | Key flags |
Expand Down
16 changes: 16 additions & 0 deletions src/apm_cli/install/outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ def diagnostic_error_count(diagnostics: object | None) -> int:
return 0


def agent_plugin_target_excluded_count(diagnostics: object | None) -> int:
"""Return the number of target-excluded Agent Plugin packages."""
if diagnostics is None:
return 0
value = getattr(diagnostics, "agent_plugin_target_excluded_count", 0)
if isinstance(value, int):
return value
if not isinstance(value, str):
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0


def install_command_outcome(result: InstallResult) -> InstallCommandOutcome:
"""Return the command outcome owned by ``InstallResult.disposition``."""
if result.disposition in _SUCCESS_SUMMARY_DISPOSITIONS:
Expand Down Expand Up @@ -143,6 +158,7 @@ def finalize_install_result(
if (
result.disposition is InstallDisposition.FAILED
or diagnostic_error_count(diagnostics) > 0
or (result.installed_count == 0 and agent_plugin_target_excluded_count(diagnostics) > 0)
or (has_critical and not force)
):
result.disposition = InstallDisposition.FAILED
Expand Down
96 changes: 84 additions & 12 deletions src/apm_cli/install/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import shlex
from pathlib import Path
from typing import TYPE_CHECKING, cast

Expand Down Expand Up @@ -118,9 +119,10 @@ def preflight_agent_plugin_materializations(
"""Reject the batch once, before any package can mutate a target.

A native Agent Plugin whose effective targets simply do not select
``copilot`` is not a failure: it is skipped per-package during integration
(:func:`_record_agent_plugin_target_skip`), so it must not abort the
batch -- ``AgentPluginTargetExcludedError`` carries that distinction.
``copilot`` is not a structural package failure: it is skipped per-package
during integration (:func:`_record_agent_plugin_target_skip`), so it must
not abort a batch that can still deploy other packages. The final install
outcome may still fail a pure no-op run.
Anything else raised here (missing canonical IR, the imperative bundle
route) is a real, actionable failure and aborts the whole batch.
"""
Expand All @@ -145,11 +147,12 @@ def preflight_agent_plugin_dry_run(
'no native harness' fallback. Admission never depends on whether a
Copilot binary exists or which version it reports.

Target exclusion (``AgentPluginTargetExcludedError``) is never fatal --
a real install skips that package with one warning and installs the
rest of the batch, so the dry-run preview must not abort the whole
preview for the same reason either. Only a genuine structural failure
(missing canonical IR, the imperative bundle route) aborts here.
Target exclusion (``AgentPluginTargetExcludedError``) is non-fatal during
preflight and per-package integration: a real install skips that package
with one warning and can still install the rest of the batch. The command
outcome owner may still fail a pure no-op install after integration. Only a
genuine structural failure (missing canonical IR, the imperative bundle
route) aborts here.
"""
from apm_cli.bundle.local_bundle import route_agent_plugin_package
from apm_cli.copilot_plugins.capability import native_registration_scope
Expand Down Expand Up @@ -242,17 +245,83 @@ def _record_agent_plugin_boundary_failure(
return deltas


def _target_names_for_hint(ctx: InstallContext) -> str:
"""Return the target argument to use in a recovery command."""
names: list[str] = []
for target in getattr(ctx, "targets", None) or ():
name = getattr(target, "name", target)
if isinstance(name, str) and name:
names.append(name)
return ",".join(sorted(set(names))) or "<target>"


def _dependency_parts_for_skill_subpath(dep_ref) -> tuple[str, str]:
"""Return dependency base and ref suffix for a skill-subpath command."""
if getattr(dep_ref, "is_local", False) and getattr(dep_ref, "local_path", None):
display = dep_ref.local_path
elif hasattr(dep_ref, "to_display_reference"):
display = dep_ref.to_display_reference()
elif hasattr(dep_ref, "get_identity"):
display = dep_ref.get_identity()
else:
display = getattr(dep_ref, "repo_url", "")
base, separator, reference = str(display).partition("#")
return base.rstrip("/"), f"{separator}{reference}" if separator else ""


def _agent_plugin_skill_name_for_hint(
source: DependencySource,
materialization: Materialization,
) -> str | None:
"""Return one skill name that can be installed through the subpath route."""
package = getattr(materialization.package_info, "package", None)
plugin = getattr(package, "agent_plugin", None)
if plugin is None or not plugin.components.skills:
return None
requested = frozenset(getattr(source.ctx, "skill_subset", None) or ())
for skill in plugin.components.skills:
if skill.directory_name in requested or skill.name in requested:
return skill.directory_name
return plugin.components.skills[0].directory_name


def _agent_plugin_target_skip_message(
source: DependencySource,
materialization: Materialization,
error: AgentPluginTargetExcludedError,
) -> str:
"""Return the actionable target-exclusion diagnostic for one package."""
message = str(error)
skill_name = _agent_plugin_skill_name_for_hint(source, materialization)
if skill_name is None:
return (
f"{message} No selected target received this package. "
"Select --target copilot or install a target-compatible package."
)

dep_base, ref_suffix = _dependency_parts_for_skill_subpath(source.dep_ref)
target_arg = _target_names_for_hint(source.ctx)
dependency = f"{dep_base}/skills/{skill_name}{ref_suffix}"
command = f"apm install {shlex.quote(dependency)} --target {shlex.quote(target_arg)}"
shell_note = " (POSIX shell)" if shlex.quote(dependency) != dependency else ""
return (
f"{message} No selected target received this package. "
f"To install available skill '{skill_name}' as a plain skill bundle, "
f"use{shell_note}: {command}"
)


def _record_agent_plugin_target_skip(
source: DependencySource,
materialization: Materialization,
error: AgentPluginTargetExcludedError,
) -> dict[str, int]:
"""Record a non-fatal skip for a package this project does not target at copilot.
"""Record a target skip for a package this project does not target at copilot.

Mirrors the per-dependency ``targets:`` subset already handled in
``finalize_native_plugin``: native registration is skipped, ONE warning
names the package, and the rest of the batch installs. This is a warning,
not an error, so the install still exits 0.
names the package, and the rest of the batch can still install. The
install outcome owner fails later only when no package deployed.
"""
ctx = source.ctx
deltas = materialization.deltas
Expand All @@ -261,7 +330,10 @@ def _record_agent_plugin_target_skip(
deltas["installed"] = 0
ctx.package_deployed_files[dep_key] = []
package_key = dep_ref.local_path if (dep_ref.is_local and dep_ref.local_path) else dep_key
ctx.diagnostics.warn(str(error), package=package_key)
ctx.diagnostics.agent_plugin_target_excluded(
_agent_plugin_target_skip_message(source, materialization, error),
package=package_key,
)
return deltas


Expand Down
35 changes: 33 additions & 2 deletions src/apm_cli/utils/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
CATEGORY_COLLISION = "collision"
CATEGORY_OVERWRITE = "overwrite"
CATEGORY_WARNING = "warning"
WARNING_AGENT_PLUGIN_TARGET_EXCLUDED = "agent_plugin_target_excluded"
# Reserved for agent source semantics dropped during target-format translation.
CATEGORY_AGENT_LOSSY_COMPILATION = "agent_lossy_compilation"
CATEGORY_ERROR = "error"
Expand Down Expand Up @@ -63,7 +64,7 @@ class Diagnostic:
category: str
package: str = ""
detail: str = ""
severity: str = "" # e.g. "critical", "warning", "info" -- used by security category
severity: str = "" # e.g. "critical", "warning", "info", or warning subtype


class DiagnosticCollector:
Expand Down Expand Up @@ -106,7 +107,13 @@ def overwrite(self, path: str, package: str = "", detail: str = "") -> None:
)
)

def warn(self, message: str, package: str = "", detail: str = "") -> None:
def warn(
self,
message: str,
package: str = "",
detail: str = "",
severity: str = "",
) -> None:
"""Record a general warning."""
with self._lock:
self._diagnostics.append(
Expand All @@ -115,9 +122,24 @@ def warn(self, message: str, package: str = "", detail: str = "") -> None:
category=CATEGORY_WARNING,
package=package,
detail=detail,
severity=severity,
)
)

def agent_plugin_target_excluded(
self,
message: str,
package: str = "",
detail: str = "",
) -> None:
"""Record an Agent Plugin target exclusion warning."""
self.warn(
message,
package=package,
detail=detail,
severity=WARNING_AGENT_PLUGIN_TARGET_EXCLUDED,
)

def lossy_agent_compilation(
self,
message: str,
Expand Down Expand Up @@ -253,6 +275,15 @@ def has_diagnostics(self) -> bool:
def error_count(self) -> int:
return sum(1 for d in self._diagnostics if d.category == CATEGORY_ERROR)

@property
def agent_plugin_target_excluded_count(self) -> int:
"""Return target-excluded Agent Plugin warning count."""
return sum(
1
for d in self._diagnostics
if d.category == CATEGORY_WARNING and d.severity == WARNING_AGENT_PLUGIN_TARGET_EXCLUDED
)

@property
def security_count(self) -> int:
"""Return number of security findings."""
Expand Down
Loading
Loading