From a0c6bb33033a25243012b1296eb992c9513982e1 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 31 Mar 2026 15:51:03 +0200 Subject: [PATCH 1/3] fix: respect metadata.pluginRoot when resolving marketplace plugins Bare-name sources like "azure-cloud-development" in marketplace.json were resolved directly under owner/repo, ignoring the pluginRoot field that specifies the base directory (e.g. "./plugins"). Now _resolve_relative_source() prepends plugin_root to bare names, producing the correct path (github/awesome-copilot/plugins/azure-cloud-development). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/marketplace/models.py | 10 +++++ src/apm_cli/marketplace/resolver.py | 26 +++++++++++- .../marketplace/test_marketplace_models.py | 22 ++++++++++ .../marketplace/test_marketplace_resolver.py | 42 +++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/marketplace/models.py b/src/apm_cli/marketplace/models.py index 478eaae1..7ccba40e 100644 --- a/src/apm_cli/marketplace/models.py +++ b/src/apm_cli/marketplace/models.py @@ -82,6 +82,7 @@ class MarketplaceManifest: plugins: Tuple[MarketplacePlugin, ...] = () owner_name: str = "" description: str = "" + plugin_root: str = "" # metadata.pluginRoot — base path for bare-name sources def find_plugin(self, plugin_name: str) -> Optional[MarketplacePlugin]: """Find a plugin by exact name (case-insensitive).""" @@ -195,6 +196,14 @@ def parse_marketplace_json( data.get("owner"), dict ) else data.get("owner", "") + # Extract pluginRoot from metadata (base path for bare-name sources) + metadata = data.get("metadata", {}) + plugin_root = "" + if isinstance(metadata, dict): + raw_root = metadata.get("pluginRoot", "") + if isinstance(raw_root, str): + plugin_root = raw_root.strip() + raw_plugins = data.get("plugins", []) if not isinstance(raw_plugins, list): logger.warning( @@ -216,4 +225,5 @@ def parse_marketplace_json( plugins=tuple(plugins), owner_name=owner_name, description=description, + plugin_root=plugin_root, ) diff --git a/src/apm_cli/marketplace/resolver.py b/src/apm_cli/marketplace/resolver.py index e8f5768c..e52f06e1 100644 --- a/src/apm_cli/marketplace/resolver.py +++ b/src/apm_cli/marketplace/resolver.py @@ -111,16 +111,33 @@ def _resolve_git_subdir_source(source: dict) -> str: return base -def _resolve_relative_source(source: str, marketplace_owner: str, marketplace_repo: str) -> str: +def _resolve_relative_source( + source: str, + marketplace_owner: str, + marketplace_repo: str, + plugin_root: str = "", +) -> str: """Resolve a relative path source to ``owner/repo[/subdir]``. Relative sources point to subdirectories within the marketplace repo itself. + When *plugin_root* is set (from ``metadata.pluginRoot`` in the manifest), + bare names (no ``/``) are resolved under that directory. """ # Normalize the relative path (strip leading ./ and trailing /) rel = source.strip("/") if rel.startswith("./"): rel = rel[2:] rel = rel.strip("/") + + # If plugin_root is set and source is a bare name, prepend it + if plugin_root and rel and "/" not in rel: + root = plugin_root.strip("/") + if root.startswith("./"): + root = root[2:] + root = root.strip("/") + if root: + rel = f"{root}/{rel}" + if rel and rel != ".": try: validate_path_segments(rel, context="relative source path") @@ -134,6 +151,7 @@ def resolve_plugin_source( plugin: MarketplacePlugin, marketplace_owner: str = "", marketplace_repo: str = "", + plugin_root: str = "", ) -> str: """Resolve a plugin's source to a canonical ``owner/repo[#ref]`` string. @@ -144,6 +162,7 @@ def resolve_plugin_source( plugin: The marketplace plugin to resolve. marketplace_owner: Owner of the marketplace repo (for relative sources). marketplace_repo: Repo name of the marketplace (for relative sources). + plugin_root: Base path for bare-name sources (from metadata.pluginRoot). Returns: Canonical ``owner/repo[#ref]`` string. @@ -157,7 +176,9 @@ def resolve_plugin_source( # String source = relative path if isinstance(source, str): - return _resolve_relative_source(source, marketplace_owner, marketplace_repo) + return _resolve_relative_source( + source, marketplace_owner, marketplace_repo, plugin_root=plugin_root + ) if not isinstance(source, dict): raise ValueError( @@ -217,6 +238,7 @@ def resolve_marketplace_plugin( plugin, marketplace_owner=source.owner, marketplace_repo=source.repo, + plugin_root=manifest.plugin_root, ) logger.debug( diff --git a/tests/unit/marketplace/test_marketplace_models.py b/tests/unit/marketplace/test_marketplace_models.py index 1b4e7ba6..2fc708ce 100644 --- a/tests/unit/marketplace/test_marketplace_models.py +++ b/tests/unit/marketplace/test_marketplace_models.py @@ -347,3 +347,25 @@ def test_owner_dict(self): data = {"name": "Test", "owner": {"name": "Jane"}, "plugins": []} manifest = parse_marketplace_json(data) assert manifest.owner_name == "Jane" + + def test_plugin_root_from_metadata(self): + """metadata.pluginRoot is parsed into manifest.plugin_root.""" + data = { + "name": "Test", + "metadata": {"pluginRoot": "./plugins"}, + "plugins": [], + } + manifest = parse_marketplace_json(data) + assert manifest.plugin_root == "./plugins" + + def test_plugin_root_missing_metadata(self): + """No metadata section -> plugin_root is empty.""" + data = {"name": "Test", "plugins": []} + manifest = parse_marketplace_json(data) + assert manifest.plugin_root == "" + + def test_plugin_root_missing_key(self): + """metadata present but no pluginRoot -> plugin_root is empty.""" + data = {"name": "Test", "metadata": {"version": "1.0"}, "plugins": []} + manifest = parse_marketplace_json(data) + assert manifest.plugin_root == "" diff --git a/tests/unit/marketplace/test_marketplace_resolver.py b/tests/unit/marketplace/test_marketplace_resolver.py index 9dbdda05..7733d05c 100644 --- a/tests/unit/marketplace/test_marketplace_resolver.py +++ b/tests/unit/marketplace/test_marketplace_resolver.py @@ -181,6 +181,40 @@ def test_path_traversal_rejected(self): with pytest.raises(ValueError, match="traversal sequence"): _resolve_relative_source("../escape", "acme-org", "marketplace") + def test_bare_name_without_plugin_root(self): + """Bare name without plugin_root resolves directly under repo.""" + result = _resolve_relative_source("my-plugin", "github", "awesome-copilot") + assert result == "github/awesome-copilot/my-plugin" + + def test_bare_name_with_plugin_root(self): + """Bare name with plugin_root gets prefixed.""" + result = _resolve_relative_source( + "azure-cloud-development", "github", "awesome-copilot", + plugin_root="./plugins", + ) + assert result == "github/awesome-copilot/plugins/azure-cloud-development" + + def test_plugin_root_without_dot_slash(self): + """plugin_root without leading ./ still works.""" + result = _resolve_relative_source( + "my-plugin", "org", "repo", plugin_root="packages", + ) + assert result == "org/repo/packages/my-plugin" + + def test_plugin_root_ignored_for_path_sources(self): + """Sources with / are already paths -- plugin_root should not apply.""" + result = _resolve_relative_source( + "./custom/path/plugin", "org", "repo", plugin_root="./plugins", + ) + assert result == "org/repo/custom/path/plugin" + + def test_plugin_root_trailing_slashes(self): + """Trailing slashes on plugin_root are normalized.""" + result = _resolve_relative_source( + "my-plugin", "org", "repo", plugin_root="./plugins/", + ) + assert result == "org/repo/plugins/my-plugin" + class TestResolvePluginSource: """Integration of all source type resolvers.""" @@ -227,6 +261,14 @@ def test_relative_source(self): p = MarketplacePlugin(name="test", source="./plugins/local") assert resolve_plugin_source(p, "acme", "mkt") == "acme/mkt/plugins/local" + def test_relative_bare_name_with_plugin_root(self): + """Bare-name source with plugin_root gets prefixed (awesome-copilot pattern).""" + p = MarketplacePlugin(name="azure-cloud-development", source="azure-cloud-development") + result = resolve_plugin_source( + p, "github", "awesome-copilot", plugin_root="./plugins" + ) + assert result == "github/awesome-copilot/plugins/azure-cloud-development" + def test_npm_source_rejected(self): p = MarketplacePlugin( name="test", From 876bcbe0587be46c26577de0a8be3388d2f4132b Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 31 Mar 2026 15:53:14 +0200 Subject: [PATCH 2/3] chore: prepare v0.8.9 hotfix release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97eb9f05..91ac1fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.9] - 2026-03-31 + +### Fixed + +- `apm install NAME@MARKETPLACE` now respects `metadata.pluginRoot` from marketplace manifests, fixing resolution of bare-name plugins in marketplaces like `awesome-copilot` (#512) +- Windows unit test assertion tolerates Rich console line-wrapping on long temp paths (#510) +- Release validation scripts match updated `apm deps list` scope output (#510) + ## [0.8.8] - 2026-03-31 ### Added diff --git a/pyproject.toml b/pyproject.toml index 0908a9de..42a78a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apm-cli" -version = "0.8.8" +version = "0.8.9" description = "MCP configuration tool" readme = "README.md" requires-python = ">=3.10" From 329f02d3a7389cfe5fa3ad73e793db153484b0cf Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 31 Mar 2026 15:59:00 +0200 Subject: [PATCH 3/3] fix: address PR review - dot source edge case, ASCII comment, docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/guides/marketplaces.md | 15 +++++++++++++++ src/apm_cli/marketplace/models.py | 2 +- src/apm_cli/marketplace/resolver.py | 2 +- .../unit/marketplace/test_marketplace_resolver.py | 7 +++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/guides/marketplaces.md b/docs/src/content/docs/guides/marketplaces.md index e906e8b3..b969022c 100644 --- a/docs/src/content/docs/guides/marketplaces.md +++ b/docs/src/content/docs/guides/marketplaces.md @@ -48,6 +48,21 @@ Both Copilot CLI and Claude Code `marketplace.json` formats are supported. Copil npm sources are not supported. Copilot CLI format uses `"repository"` and optional `"ref"` fields instead of `"source"`. +### Plugin root directory + +Marketplaces can declare a `metadata.pluginRoot` field to specify the base directory for bare-name sources: + +```json +{ + "metadata": { "pluginRoot": "./plugins" }, + "plugins": [ + { "name": "my-tool", "source": "my-tool" } + ] +} +``` + +With `pluginRoot` set to `./plugins`, the source `"my-tool"` resolves to `owner/repo/plugins/my-tool`. Sources that already contain a path separator (e.g. `./custom/path`) are not affected by `pluginRoot`. + ## Register a marketplace ```bash diff --git a/src/apm_cli/marketplace/models.py b/src/apm_cli/marketplace/models.py index 7ccba40e..18a4484e 100644 --- a/src/apm_cli/marketplace/models.py +++ b/src/apm_cli/marketplace/models.py @@ -82,7 +82,7 @@ class MarketplaceManifest: plugins: Tuple[MarketplacePlugin, ...] = () owner_name: str = "" description: str = "" - plugin_root: str = "" # metadata.pluginRoot — base path for bare-name sources + plugin_root: str = "" # metadata.pluginRoot - base path for bare-name sources def find_plugin(self, plugin_name: str) -> Optional[MarketplacePlugin]: """Find a plugin by exact name (case-insensitive).""" diff --git a/src/apm_cli/marketplace/resolver.py b/src/apm_cli/marketplace/resolver.py index e52f06e1..6c84fe7c 100644 --- a/src/apm_cli/marketplace/resolver.py +++ b/src/apm_cli/marketplace/resolver.py @@ -130,7 +130,7 @@ def _resolve_relative_source( rel = rel.strip("/") # If plugin_root is set and source is a bare name, prepend it - if plugin_root and rel and "/" not in rel: + if plugin_root and rel and rel != "." and "/" not in rel: root = plugin_root.strip("/") if root.startswith("./"): root = root[2:] diff --git a/tests/unit/marketplace/test_marketplace_resolver.py b/tests/unit/marketplace/test_marketplace_resolver.py index 7733d05c..8bc20d3c 100644 --- a/tests/unit/marketplace/test_marketplace_resolver.py +++ b/tests/unit/marketplace/test_marketplace_resolver.py @@ -215,6 +215,13 @@ def test_plugin_root_trailing_slashes(self): ) assert result == "org/repo/plugins/my-plugin" + def test_dot_source_with_plugin_root(self): + """source='.' means repo root -- plugin_root must not apply.""" + result = _resolve_relative_source( + ".", "org", "repo", plugin_root="./plugins", + ) + assert result == "org/repo" + class TestResolvePluginSource: """Integration of all source type resolvers."""