Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .apm/architecture/owners/marketplace-plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@
{
"id": "legacy-plugin-skill-membership",
"decision": "Legacy plugin declared-skill membership and plugin-root placeholder expansion",
"owner": "deps/plugin_parser.py (_map_plugin_artifacts, normalized_plugin_skill_sources, resolve_plugin_root_placeholders)",
"owner": "deps/plugin_parser.py (membership facade) and compilation/link_resolver.py (token expansion)",
"selectors": [
"src/apm_cli/compilation/link_resolver.py",
"src/apm_cli/deps/plugin_parser.py",
"src/apm_cli/integration/skill_integrator.py"
],
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Legacy Claude plugin agents now resolve `${CLAUDE_PLUGIN_ROOT}` to their
retained package under `apm_modules` across all projected targets, including
Codex and Kiro. (closes #2692)
- `apm pack --check-clean` is now read-only and detects marketplace drift
without overwriting artifacts. Release pipelines that also produce artifacts
must run `apm pack` separately; see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ my-package/

File names end in `.agent.md` and live under `.apm/agents/`.

### Legacy Claude plugin bundles

APM retains the complete downloaded legacy Claude plugin under `apm_modules` and
projects its agents into each target's discovery directory. For legacy targets,
APM resolves Claude's official `${CLAUDE_PLUGIN_ROOT}` token in agent content to
the retained package. Inline Markdown links to existing package assets are also
rewritten.

A manifest-declared agent directory recursively treats every Markdown file as
an agent. If it also contains supporting Markdown that should not be invokable,
declare exact agent files:

```json
{
"agents": ["./agents/my-agent/my-agent.md"]
}
```

### Frontmatter

```markdown
Expand Down Expand Up @@ -216,7 +234,6 @@ offending package and field so you can fix the source.
| opencode | `.opencode/agents/<name>.md` | verbatim |
| codex | `.codex/agents/<name>.toml` | `name` and `description` -> TOML; body becomes `developer_instructions`; unsupported `tools` emits a warning |
| kiro | `.kiro/agents/<relative-stem>.md` | `description`, `model`, `tools` kept; `name` and unknown fields stripped; identity from path; fail closed on unsupported tools (ref: [kiro.dev/docs/custom-agents](https://kiro.dev/docs/custom-agents/), accessed 2026-08-03) |
| grok-build | `.grok/agents/<name>.md` | verbatim |
| windsurf | not deployed | Windsurf has no agents primitive -- author personas as skills (Cascade auto-invokes by description) |
Comment thread
Copilot marked this conversation as resolved.
| gemini | not deployed | Gemini CLI has no agents primitive |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ how to install it:
| `plugin.json` (no `$schema`) / `.claude-plugin/` | Claude plugin collection | Dissect via plugin artifact mapping |
| `plugin.json` with an Agent Plugins `$schema` | Portable Agent Plugin | Acquired and locked as one opaque unit; registered when effective targets include Copilot and admission gates pass. Excluded targets create no native registration or loose primitive projection. APM does not require the runtime during lifecycle operations; loading requires supported Copilot CLI 1.0.81 or newer |

Legacy Claude plugin collections remain materialized under `apm_modules`; APM
projects their agents into target discovery directories. For legacy targets,
APM resolves Claude's official `${CLAUDE_PLUGIN_ROOT}` token and rewrites inline
Markdown links to existing package assets. A manifest-declared agent directory
recursively treats every Markdown file as an agent. Declare exact agent files
for mixed directories.

For Agent Plugins with the same declared name, a direct dependency wins over a
transitive dependency. APM refuses same-precedence collisions and does not
silently repoint a ledger-recorded owner to a transitive claimant.
Expand Down Expand Up @@ -868,7 +875,7 @@ is a hard error -- run `migrate` to consolidate.

### Full guide

See [docs/guides/marketplace-authoring](../../../../../docs/src/content/docs/guides/marketplace-authoring.md)
See the [marketplace publishing guide](../../../../../docs/src/content/docs/producer/publish-to-a-marketplace.md)
for the complete maintainer workflow (quickstart, version ranges, `check`,
`doctor`, and `outdated`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_def_body_text,
_forbid_scan,
_load,
_require_res,
_require_subs,
_src_python,
_subdir_python,
Expand Down Expand Up @@ -204,6 +205,9 @@ def _scan_normalization_callers(


_SKILL_INTEGRATOR = "src/apm_cli/integration/skill_integrator.py"
_PLUGIN_ROOT_OWNER = "src/apm_cli/compilation/link_resolver.py"
_BASE_INTEGRATOR = "src/apm_cli/integration/base_integrator.py"
_DRIFT = "src/apm_cli/install/drift.py"


_SKILL_SUBSET_LEXICAL = re.compile(
Expand All @@ -228,6 +232,46 @@ def _check_legacy_skill_membership(provider: FactsProvider) -> tuple[Violation,
"plugin_parser must own skill membership and artifact mapping",
)
)
findings.extend(
_require_res(
provider,
inv,
_RID_SKILL,
_PLUGIN_ROOT_OWNER,
(re.compile(r"^def resolve_claude_plugin_root\("),),
"link_resolver must own Claude plugin-root token expansion",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_SKILL,
_PLUGIN_PARSER,
("resolve_claude_plugin_root(value, plugin_path)",),
"plugin_parser must delegate plugin-root expansion to link_resolver",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_SKILL,
_BASE_INTEGRATOR,
("package_info.claude_plugin_root or install_path",),
"integrators must consume the durable Claude plugin root",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_SKILL,
_DRIFT,
("package_info.claude_plugin_root = dependency_ref.get_install_path(",),
"drift replay must preserve the live Claude plugin root",
)
)

integrator_facts, failures = _load(provider, inv, _RID_SKILL, _SKILL_INTEGRATOR, parse=True)
if failures:
Expand Down
11 changes: 11 additions & 0 deletions src/apm_cli/compilation/link_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
from apm_cli.utils.path_security import PathTraversalError, ensure_path_within
from apm_cli.utils.paths import portable_link_relpath

CLAUDE_PLUGIN_ROOT = "${CLAUDE_PLUGIN_ROOT}"


def resolve_claude_plugin_root(content: str, plugin_root: Path) -> str:
"""Expand Claude's exact plugin-root token to the materialized package."""
return content.replace(CLAUDE_PLUGIN_ROOT, plugin_root.resolve().as_posix())


# CRITICAL: Shadow Click commands to prevent namespace collision
set = builtins.set
list = builtins.list
Expand Down Expand Up @@ -80,6 +88,7 @@ def __init__(self, base_dir: Path):
# the generalization safely.
self.package_root: Path | None = None
self.deployment_package_root: Path | None = None
self.claude_plugin_root: Path | None = None

def register_contexts(self, primitives) -> None:
"""Build registry of all available context files.
Expand Down Expand Up @@ -145,6 +154,8 @@ def resolve_links_for_installation(
deployment_package_root=self.deployment_package_root,
)

if self.claude_plugin_root is not None:
content = resolve_claude_plugin_root(content, self.claude_plugin_root)
return self._rewrite_markdown_links(content, ctx)

def resolve_links_for_compilation(
Expand Down
4 changes: 3 additions & 1 deletion src/apm_cli/deps/plugin_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,9 @@ def _substitute_plugin_root(
def resolve_plugin_root_placeholders(value: Any, plugin_path: Path) -> Any:
"""Resolve plugin-root placeholders in an in-memory manifest value."""
if isinstance(value, str):
return value.replace("${CLAUDE_PLUGIN_ROOT}", str(plugin_path.resolve()))
from apm_cli.compilation.link_resolver import resolve_claude_plugin_root

return resolve_claude_plugin_root(value, plugin_path)
if isinstance(value, dict):
return {
key: resolve_plugin_root_placeholders(item, plugin_path) for key, item in value.items()
Expand Down
11 changes: 7 additions & 4 deletions src/apm_cli/install/drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
if config.modules_root is not None
else project_root / "apm_modules"
)
live_modules_dir = project_root / "apm_modules"
live_root = project_root / "apm_modules"

# Honor apm.yml's ``target:`` field so multi-target projects replay
# into all governed roots (not just whichever directory happens to
Expand Down Expand Up @@ -638,7 +638,7 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
apm_modules_dir,
cache_only=config.cache_only,
lockfile=lock,
live_modules_dir=live_modules_dir,
live_modules_dir=live_root,
downloader=downloader,
registry_resolver=registry_resolver,
registries=registries,
Expand All @@ -654,10 +654,13 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
if lock_dep.local_path == _SELF_KEY:
package_info.root_local_project_root = project_root
package_info.deployment_package_root = scratch_root
package_info.claude_plugin_root = project_root
else:
package_info.deployment_package_root = (
lock_dep.to_dependency_ref().get_install_path(scratch_root / "apm_modules")
dependency_ref = lock_dep.to_dependency_ref()
package_info.deployment_package_root = dependency_ref.get_install_path(
scratch_root / "apm_modules"
)
package_info.claude_plugin_root = dependency_ref.get_install_path(live_root)
dep_key = lock_dep.get_unique_key()

integrate_package_primitives(
Expand Down
Loading