diff --git a/.apm/architecture/owners/marketplace-plugins.json b/.apm/architecture/owners/marketplace-plugins.json index 768608310..34ae67542 100644 --- a/.apm/architecture/owners/marketplace-plugins.json +++ b/.apm/architecture/owners/marketplace-plugins.json @@ -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" ], diff --git a/CHANGELOG.md b/CHANGELOG.md index f8db6e63d..0871a04fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md index 0f442e8c3..c311630bd 100644 --- a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md +++ b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md @@ -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 @@ -216,7 +234,6 @@ offending package and field so you can fix the source. | opencode | `.opencode/agents/.md` | verbatim | | codex | `.codex/agents/.toml` | `name` and `description` -> TOML; body becomes `developer_instructions`; unsupported `tools` emits a warning | | kiro | `.kiro/agents/.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/.md` | verbatim | | windsurf | not deployed | Windsurf has no agents primitive -- author personas as skills (Cascade auto-invokes by description) | | gemini | not deployed | Gemini CLI has no agents primitive | diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 6aba33e1c..6a896dc45 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -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. @@ -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`). diff --git a/scripts/architecture_linter/checks/marketplace_package_and_registration.py b/scripts/architecture_linter/checks/marketplace_package_and_registration.py index 6fb310d47..ea22d73b3 100644 --- a/scripts/architecture_linter/checks/marketplace_package_and_registration.py +++ b/scripts/architecture_linter/checks/marketplace_package_and_registration.py @@ -20,6 +20,7 @@ _def_body_text, _forbid_scan, _load, + _require_res, _require_subs, _src_python, _subdir_python, @@ -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( @@ -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: diff --git a/src/apm_cli/compilation/link_resolver.py b/src/apm_cli/compilation/link_resolver.py index 2414c7982..72154375d 100644 --- a/src/apm_cli/compilation/link_resolver.py +++ b/src/apm_cli/compilation/link_resolver.py @@ -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 @@ -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. @@ -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( diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py index 795b33455..b7e625498 100644 --- a/src/apm_cli/deps/plugin_parser.py +++ b/src/apm_cli/deps/plugin_parser.py @@ -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() diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index c6bd857ad..0e2d70703 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -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 @@ -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, @@ -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( diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 284346851..6e41ec9bf 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -15,7 +15,7 @@ from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.integration.opencode_frontmatter import validate_opencode_frontmatter -from apm_cli.utils.atomic_io import normalize_crlf_to_lf, write_text_lf +from apm_cli.utils.atomic_io import write_text_lf from apm_cli.utils.diagnostics import printable_ascii_text from apm_cli.utils.path_security import PathTraversalError, ensure_path_within from apm_cli.utils.paths import portable_relpath @@ -171,12 +171,20 @@ def integrate_agents_for_target( files_skipped += 1 continue + if source_file.is_symlink(): + raise ValueError(f"Refusing to read symlink source: {source_file}") + source_content = source_file.read_text(encoding="utf-8") + resolved_content, links_resolved = self.resolve_links( + source_content, source_file, target_path + ) + if mapping.format_id == "kiro_agent": # req-tg-009: Preflight render+validate MUST run before any # content-identity adoption fast-path or filesystem mutation. # If validation fails, skip without writing or creating dirs. rendered, ok = self._preflight_render_kiro_agent( source_file, + content=resolved_content, diagnostics=diagnostics, package_name=package_info.package.name, ) @@ -184,24 +192,22 @@ def integrate_agents_for_target( files_skipped += 1 continue - # Compare rendered artifact (not raw source) against existing - # target so a pre-placed file with invalid tools cannot be - # adopted by matching source bytes. rel_path = portable_relpath(target_path, project_root) - if target_path.exists() and not target_path.is_symlink(): - try: - existing = target_path.read_bytes() - rendered_bytes = normalize_crlf_to_lf(rendered).encode("utf-8") - if existing == rendered_bytes: - target_paths.append(target_path) - files_adopted += 1 - continue - except OSError: - pass - if self.check_collision( - target_path, rel_path, managed_files, force, diagnostics=diagnostics - ): - files_skipped += 1 + skip, adopted = self._check_adopt_or_skip( + target_path, + source_file, + rel_path, + managed_files, + force, + diagnostics, + target_paths, + expected_content=rendered, + ) + if skip: + if adopted: + files_adopted += 1 + else: + files_skipped += 1 continue # Safe to materialize: ensure parent dirs exist then write. @@ -210,6 +216,7 @@ def integrate_agents_for_target( agents_dir_created = True target_path.parent.mkdir(parents=True, exist_ok=True) write_text_lf(target_path, rendered) + total_links_resolved += links_resolved files_integrated += 1 target_paths.append(target_path) continue @@ -221,8 +228,25 @@ def integrate_agents_for_target( rel_path = portable_relpath(target_path, project_root) + if mapping.format_id == "codex_agent": + rendered = self._render_codex_agent( + source_file, + resolved_content, + diagnostics=diagnostics, + package_name=package_info.package.name, + ) + else: + rendered = resolved_content + skip, adopted = self._check_adopt_or_skip( - target_path, source_file, rel_path, managed_files, force, diagnostics, target_paths + target_path, + source_file, + rel_path, + managed_files, + force, + diagnostics, + target_paths, + expected_content=rendered, ) if skip: if adopted: @@ -232,19 +256,13 @@ def integrate_agents_for_target( continue if mapping.format_id == "codex_agent": - self._write_codex_agent( - source_file, - target_path, - diagnostics=diagnostics, - package_name=package_info.package.name, - ) - links_resolved = 0 + write_text_lf(target_path, rendered) else: if mapping.format_id == "opencode_agent": self._warn_opencode_frontmatter( source_file, diagnostics, package_info.package.name ) - links_resolved = self.copy_agent(source_file, target_path) + write_text_lf(target_path, rendered) total_links_resolved += links_resolved files_integrated += 1 target_paths.append(target_path) @@ -414,24 +432,20 @@ def _warn_codex_tools_dropped( ) @staticmethod - def _write_codex_agent( + def _render_codex_agent( source: Path, - target: Path, + content: str, *, diagnostics: DiagnosticCollector | None = None, package_name: str = "", - ) -> None: - """Transform an ``.agent.md`` file to Codex ``.toml`` format. + ) -> str: + """Transform agent Markdown content to Codex TOML. Parses YAML frontmatter for ``name`` and ``description``, uses the markdown body as ``developer_instructions``. """ - if source.is_symlink(): - raise ValueError(f"Refusing to read symlink source: {source}") import toml as _toml - content = source.read_text(encoding="utf-8") - name = source.stem if name.endswith(".agent"): name = name[: -len(".agent")] @@ -475,7 +489,27 @@ def _write_codex_agent( "description": description, "developer_instructions": body.strip(), } - write_text_lf(target, _toml.dumps(doc)) + return _toml.dumps(doc) + + @staticmethod + def _write_codex_agent( + source: Path, + target: Path, + *, + diagnostics: DiagnosticCollector | None = None, + package_name: str = "", + ) -> None: + """Read and transform an agent Markdown file to Codex TOML.""" + if source.is_symlink(): + raise ValueError(f"Refusing to read symlink source: {source}") + content = source.read_text(encoding="utf-8") + rendered = AgentIntegrator._render_codex_agent( + source, + content, + diagnostics=diagnostics, + package_name=package_name, + ) + write_text_lf(target, rendered) # ------------------------------------------------------------------ # Kiro agent transformer (MD -> filtered MD) @@ -513,6 +547,7 @@ def _kiro_agent_relpath(source_file: Path, package_path: Path) -> str: def _preflight_render_kiro_agent( source: Path, *, + content: str | None = None, diagnostics=None, package_name: str = "", ) -> tuple[str | None, bool]: @@ -534,7 +569,8 @@ def _preflight_render_kiro_agent( if source.is_symlink(): raise ValueError(f"Refusing to read symlink source: {source}") - content = source.read_text(encoding="utf-8") + if content is None: + content = source.read_text(encoding="utf-8") body = content out_fm: dict = {} diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index fc6ad18e2..dcc5d4ece 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -396,6 +396,7 @@ def _check_adopt_or_skip( force: bool, diagnostics, target_paths: list, + expected_content: str | None = None, ) -> tuple[bool, bool]: """Check whether *target_path* should be adopted or skipped. @@ -431,9 +432,20 @@ def _check_adopt_or_skip( is ``True`` only when the existing file already matched the deployed content and has been silently adopted. """ - if self.is_content_identical_to_source( + identical = False + if expected_content is not None and not target_path.is_symlink(): + try: + expected = expected_content + if self._LF_NORMALIZED_DEPLOY: + expected = normalize_crlf_to_lf(expected) + identical = _read_bytes_no_follow(target_path) == expected.encode("utf-8") + except OSError: + identical = False + elif self.is_content_identical_to_source( target_path, source_file, lf_normalized_deploy=self._LF_NORMALIZED_DEPLOY ): + identical = True + if identical: target_paths.append(target_path) return True, True if self.check_collision( @@ -771,6 +783,15 @@ def init_link_resolver(self, package_info, project_root: Path) -> None: self.link_resolver.deployment_package_root = Path( package_info.deployment_package_root or install_path ) + from apm_cli.models.validation import PackageType + + if ( + getattr(package_info, "package_type", None) + is PackageType.MARKETPLACE_PLUGIN + ): + self.link_resolver.claude_plugin_root = Path( + package_info.claude_plugin_root or install_path + ) except Exception: self.link_resolver = None diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index 0aee77e78..87b5a172a 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -771,6 +771,7 @@ class PackageInfo: package_type: PackageType | None = None # APM_PACKAGE, CLAUDE_SKILL, or HYBRID root_local_project_root: Path | None = None deployment_package_root: Path | None = None # Source root in the deployment output frame + claude_plugin_root: Path | None = None # Durable bundle root emitted into plugin content def get_canonical_dependency_string(self) -> str: """Get the canonical dependency string for this package. diff --git a/tests/unit/compilation/test_link_resolver_phase3.py b/tests/unit/compilation/test_link_resolver_phase3.py index 5575f75da..2df184236 100644 --- a/tests/unit/compilation/test_link_resolver_phase3.py +++ b/tests/unit/compilation/test_link_resolver_phase3.py @@ -30,6 +30,7 @@ UnifiedLinkResolver, _remove_frontmatter, _resolve_path, + resolve_claude_plugin_root, resolve_markdown_links, validate_link_targets, ) @@ -88,6 +89,37 @@ def test_dependency_context_registered_with_qualified_name( assert "org/repo:dep.context.md" in resolver.context_registry +def test_resolve_claude_plugin_root_replaces_only_exact_token(base_dir: Path) -> None: + """Plugin-root expansion does not guess at bare or variant paths.""" + plugin_root = base_dir / "apm_modules" / "_local" / "plugin" + content = ( + "${CLAUDE_PLUGIN_ROOT}/scripts/run.py\n" + "${claude_plugin_root}/scripts/lower.py\n" + "scripts/bare.py\n" + ) + + resolved = resolve_claude_plugin_root(content, plugin_root) + + assert resolved == ( + f"{plugin_root.resolve().as_posix()}/scripts/run.py\n" + "${claude_plugin_root}/scripts/lower.py\n" + "scripts/bare.py\n" + ) + + +def test_resolve_claude_plugin_root_uses_yaml_safe_windows_separators() -> None: + """Windows roots do not inject YAML escape sequences into agent content.""" + plugin_root = MagicMock(spec=Path) + plugin_root.resolve.return_value.as_posix.return_value = "C:/Users/dev/plugin" + + resolved = resolve_claude_plugin_root( + 'path: "${CLAUDE_PLUGIN_ROOT}/scripts/run.py"\n', + plugin_root, + ) + + assert resolved == 'path: "C:/Users/dev/plugin/scripts/run.py"\n' + + # --------------------------------------------------------------------------- # _is_external_url # --------------------------------------------------------------------------- diff --git a/tests/unit/install/test_drift.py b/tests/unit/install/test_drift.py index 7971f4114..77629bd94 100644 --- a/tests/unit/install/test_drift.py +++ b/tests/unit/install/test_drift.py @@ -745,6 +745,7 @@ def _spy_integrate(*args, **kwargs): { "target_names": sorted(t.name for t in kwargs.get("targets", [])), "dep_target_subset": kwargs.get("dep_target_subset"), + "claude_plugin_root": args[0].claude_plugin_root, } ) return {"deployed_files": []} @@ -773,6 +774,8 @@ def _spy_integrate(*args, **kwargs): assert call["dep_target_subset"] == ["claude"], ( f"dep_target_subset should be ['claude'], got {call['dep_target_subset']}" ) + expected_plugin_root = dep.to_dependency_ref().get_install_path(project_root / "apm_modules") + assert call["claude_plugin_root"] == expected_plugin_root def test_run_replay_threads_locked_skill_subset( diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 4cc98ced7..1e970cc24 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -6,7 +6,13 @@ from unittest.mock import Mock from apm_cli.integration import AgentIntegrator -from apm_cli.models.apm_package import APMPackage, GitReferenceType, PackageInfo, ResolvedReference +from apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + PackageType, + ResolvedReference, +) from apm_cli.utils.diagnostics import ( CATEGORY_AGENT_LOSSY_COMPILATION, CATEGORY_WARNING, @@ -552,6 +558,107 @@ def test_find_agent_files_discovers_nested_subdirectories(self): assert "nested.agent.md" in names assert "plain-nested.md" in names + def test_legacy_plugin_root_resolves_for_plain_codex_and_kiro_targets(self): + """Every target renders agent content through the shared resolver.""" + import toml + + from apm_cli.integration.targets import KNOWN_TARGETS + + package_dir = self.project_root / "apm_modules" / "_local" / "plugin" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + source = agents_dir / "builder.md" + source.write_text( + "---\nname: builder\ndescription: Builds assets\n---\n" + "Run `${CLAUDE_PLUGIN_ROOT}/scripts/build.py`.\n", + encoding="utf-8", + ) + package = APMPackage(name="plugin", version="1.0.0", package_path=package_dir) + package_info = PackageInfo( + package=package, + install_path=package_dir, + package_type=PackageType.MARKETPLACE_PLUGIN, + ) + (self.project_root / ".codex").mkdir() + (self.project_root / ".kiro").mkdir() + + expected_root = str(package_dir.resolve()) + for target_name in ("copilot", "codex", "kiro"): + result = self.integrator.integrate_agents_for_target( + KNOWN_TARGETS[target_name], + package_info, + self.project_root, + ) + assert result.files_integrated == 1 + output = result.target_paths[0].read_text(encoding="utf-8") + if target_name == "codex": + output = toml.loads(output)["developer_instructions"] + assert f"{expected_root}/scripts/build.py" in output + assert "${CLAUDE_PLUGIN_ROOT}" not in output + + adopted = self.integrator.integrate_agents_for_target( + KNOWN_TARGETS[target_name], + package_info, + self.project_root, + ) + assert adopted.files_adopted == 1 + assert adopted.files_skipped == 0 + + def test_plugin_root_uses_stable_deployment_path_during_replay(self): + """Replay output must match the path used by the original installation.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + replay_root = self.project_root / "replay" / "plugin" + agents_dir = replay_root / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "builder.md").write_text( + "Run ${CLAUDE_PLUGIN_ROOT}/scripts/build.py.\n", + encoding="utf-8", + ) + deployment_root = self.project_root / "apm_modules" / "owner" / "plugin" + package_info = PackageInfo( + package=APMPackage(name="plugin", version="1.0.0", package_path=replay_root), + install_path=replay_root, + deployment_package_root=deployment_root, + claude_plugin_root=deployment_root, + package_type=PackageType.MARKETPLACE_PLUGIN, + ) + + result = self.integrator.integrate_agents_for_target( + KNOWN_TARGETS["copilot"], + package_info, + self.project_root, + ) + + output = result.target_paths[0].read_text(encoding="utf-8") + assert f"{deployment_root.resolve().as_posix()}/scripts/build.py" in output + assert replay_root.resolve().as_posix() not in output + + def test_plugin_root_token_is_not_resolved_for_apm_packages(self): + """Claude's plugin token remains literal outside legacy plugins.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + package_dir = self.project_root / "package" + agents_dir = package_dir / ".apm" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "builder.agent.md").write_text( + "Use ${CLAUDE_PLUGIN_ROOT}/scripts/build.py.\n", + encoding="utf-8", + ) + package_info = PackageInfo( + package=APMPackage(name="package", version="1.0.0", package_path=package_dir), + install_path=package_dir, + package_type=PackageType.APM_PACKAGE, + ) + + result = self.integrator.integrate_agents_for_target( + KNOWN_TARGETS["copilot"], + package_info, + self.project_root, + ) + + assert "${CLAUDE_PLUGIN_ROOT}" in result.target_paths[0].read_text(encoding="utf-8") + def test_get_target_filename_plain_md(self): """Plain .md files get renamed to .agent.md for .github/agents/.""" source = Path("/package/.apm/agents/context-architect.md") diff --git a/tests/unit/test_plugin_parser.py b/tests/unit/test_plugin_parser.py index bb6cebbc2..1be208a0e 100644 --- a/tests/unit/test_plugin_parser.py +++ b/tests/unit/test_plugin_parser.py @@ -843,6 +843,35 @@ def test_custom_agents_dir_list_flattens_contents(self, tmp_path): "Should not create nested agents/agents/ directory" ) + def test_agent_directory_is_recursive_but_exact_file_excludes_support_docs(self, tmp_path): + """Manifest entry shape, not frontmatter, owns agent membership.""" + plugin_dir = tmp_path / "plugin" + agent_dir = plugin_dir / "agents" / "builder" + guides_dir = agent_dir / "guides" + guides_dir.mkdir(parents=True) + (agent_dir / "builder.md").write_text("# Builder") + (guides_dir / "reference.md").write_text("# Reference") + + recursive_apm = plugin_dir / "recursive" / ".apm" + recursive_apm.mkdir(parents=True) + _map_plugin_artifacts( + plugin_dir, + recursive_apm, + manifest={"agents": ["./agents/builder"]}, + ) + assert (recursive_apm / "agents" / "builder.md").is_file() + assert (recursive_apm / "agents" / "guides" / "reference.md").is_file() + + exact_apm = plugin_dir / "exact" / ".apm" + exact_apm.mkdir(parents=True) + _map_plugin_artifacts( + plugin_dir, + exact_apm, + manifest={"agents": ["./agents/builder/builder.md"]}, + ) + assert (exact_apm / "agents" / "builder.md").is_file() + assert not (exact_apm / "agents" / "guides").exists() + class TestGenerateApmYml: def test_generate_full_metadata(self):