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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/guides/marketplaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/apm_cli/marketplace/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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(
Expand All @@ -216,4 +225,5 @@ def parse_marketplace_json(
plugins=tuple(plugins),
owner_name=owner_name,
description=description,
plugin_root=plugin_root,
)
26 changes: 24 additions & 2 deletions src/apm_cli/marketplace/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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")
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -217,6 +238,7 @@ def resolve_marketplace_plugin(
plugin,
marketplace_owner=source.owner,
marketplace_repo=source.repo,
plugin_root=manifest.plugin_root,
)

logger.debug(
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/marketplace/test_marketplace_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
49 changes: 49 additions & 0 deletions tests/unit/marketplace/test_marketplace_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,47 @@ 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"

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."""
Expand Down Expand Up @@ -227,6 +268,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",
Expand Down
Loading