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

### Added

- `apm marketplace add <URL>` accepts HTTPS URLs for registering Agent Skills discovery indexes, with automatic `.well-known/agent-skills/index.json` resolution for bare origins (#676, #691)
- Agent Skills Discovery RFC v0.2.0 index parser with strict `$schema` validation, skill name rules, and digest verification (#676, #691)
- SHA-256 digest computation and integrity verification for URL-based marketplace indexes (#676, #691)
- ETag/Last-Modified conditional refresh for URL marketplace indexes with stale-while-revalidate fallback (#676, #691)
- Archive download and extraction support for `type: "archive"` skill entries with path traversal and decompression bomb safety guards (#676, #691)
- Lockfile provenance fields (`source_url`, `source_digest`) for URL-sourced marketplace dependencies (#676, #691)
- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)

### Fixed
Expand Down
24 changes: 24 additions & 0 deletions docs/src/content/docs/guides/marketplaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ With `pluginRoot` set to `./plugins`, the source `"my-tool"` resolves to `owner/

## Register a marketplace

### From a GitHub repository

```bash
apm marketplace add acme/plugin-marketplace
```
Expand All @@ -85,6 +87,28 @@ apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com
apm marketplace add ghes.corp.example.com/acme/plugin-marketplace
```

### From a URL

```bash
apm marketplace add https://plugins.example.com
```

APM automatically appends `/.well-known/agent-skills/index.json` to bare origins, following the Agent Skills Discovery RFC v0.2.0. You can also pass the full index URL:

```bash
apm marketplace add https://plugins.example.com/.well-known/agent-skills/index.json
```

The index must conform to the Agent Skills RFC schema (`$schema: "https://aka.ms/agent-skills-discovery/v0.2.0/schema"`). Plugins may use `type: "skill-md"` (direct Markdown content) or `type: "archive"` (downloadable `.tar.gz` archive). APM enforces HTTPS-only, validates SHA-256 digests when present, and supports conditional refresh via `ETag`/`Last-Modified` headers.

**Options:**
- `--name/-n` -- Custom display name for the marketplace

```bash
# Register with a custom name
apm marketplace add https://plugins.example.com --name company-skills
```

## List registered marketplaces

```bash
Expand Down
18 changes: 13 additions & 5 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,7 @@ apm mcp show a5e8a7f0-d4e4-4a1d-b12f-2896a23fd4f1

### `apm marketplace` - Plugin marketplace management

Register, browse, and manage plugin marketplaces. Marketplaces are GitHub repositories containing a `marketplace.json` index of plugins.
Register, browse, and manage plugin marketplaces. Marketplaces are GitHub repositories containing a `marketplace.json` index of plugins, or HTTPS URLs serving an Agent Skills Discovery index.

> See the [Marketplaces guide](../../guides/marketplaces/) for concepts and workflows.

Expand All @@ -947,26 +947,28 @@ apm marketplace COMMAND [OPTIONS]

#### `apm marketplace add` - Register a marketplace

Register a GitHub repository as a plugin marketplace.
Register a GitHub repository or HTTPS URL as a plugin marketplace.

```bash
apm marketplace add OWNER/REPO [OPTIONS]
apm marketplace add HOST/OWNER/REPO [OPTIONS]
apm marketplace add URL [OPTIONS]
```

**Arguments:**
- `OWNER/REPO` - GitHub repository containing `marketplace.json`
- `HOST/OWNER/REPO` - Repository on a non-github.com host (e.g., GitHub Enterprise)
- `URL` - HTTPS URL to an Agent Skills Discovery index (bare origins auto-resolve to `/.well-known/agent-skills/index.json`)

**Options:**
- `-n, --name TEXT` - Custom display name for the marketplace
- `-b, --branch TEXT` - Branch to track (default: main)
- `--host TEXT` - Git host FQDN (default: github.com or `GITHUB_HOST` env var)
- `-b, --branch TEXT` - Branch to track (default: main; GitHub sources only)
- `--host TEXT` - Git host FQDN (default: github.com or `GITHUB_HOST` env var; GitHub sources only)
- `-v, --verbose` - Show detailed output

**Examples:**
```bash
# Register a marketplace
# Register a GitHub marketplace
apm marketplace add acme/plugin-marketplace

# Register with a custom name and branch
Expand All @@ -975,6 +977,12 @@ apm marketplace add acme/plugin-marketplace --name acme-plugins --branch release
# Register from a GitHub Enterprise host
apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com
apm marketplace add ghes.corp.example.com/acme/plugin-marketplace

# Register a URL-based marketplace (bare origin)
apm marketplace add https://plugins.example.com

# Register with full index URL and custom name
apm marketplace add https://plugins.example.com/.well-known/agent-skills/index.json --name company-skills
```

#### `apm marketplace list` - List registered marketplaces
Expand Down
1 change: 1 addition & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm marketplace add OWNER/REPO` | Register a marketplace | `-n NAME`, `-b BRANCH`, `--host HOST` |
| `apm marketplace add URL` | Register a URL-based marketplace | `-n NAME` |
| `apm marketplace list` | List registered marketplaces | -- |
| `apm marketplace browse NAME` | Browse marketplace packages | -- |
| `apm marketplace update [NAME]` | Update marketplace index | -- |
Expand Down
114 changes: 99 additions & 15 deletions src/apm_cli/commands/marketplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import builtins
import sys
from urllib.parse import urlparse

import click

Expand All @@ -15,6 +16,35 @@
# Restore builtins shadowed by subcommand names
list = builtins.list

_WELL_KNOWN_PATH = "/.well-known/agent-skills/index.json"


def _resolve_index_url(raw_url: str) -> str:
"""Resolve a bare origin URL to the Agent Skills .well-known index URL.

If the URL already has a non-trivial path it is returned unchanged.
Trailing slashes on bare origins are normalised away.

Args:
raw_url: A user-supplied ``https://`` URL -- either a bare origin
(``https://example.com``) or a fully-qualified index URL.

Returns:
Fully-qualified index URL ending in
``/.well-known/agent-skills/index.json``, or *raw_url* unchanged
when it already contains a meaningful path.
"""
parsed = urlparse(raw_url)
path = parsed.path.rstrip("/")
if not path or path == "/.well-known/agent-skills":
# Bare origin or just the .well-known dir -- append full path
base = f"{parsed.scheme}://{parsed.netloc}"
resolved = base + _WELL_KNOWN_PATH
if parsed.query:
resolved += "?" + parsed.query
return resolved
return raw_url


@click.group(help="Manage plugin marketplaces for discovery and governance")
def marketplace():
Expand All @@ -29,18 +59,66 @@ def marketplace():

@marketplace.command(help="Register a plugin marketplace")
@click.argument("repo", required=True)
@click.option("--name", "-n", default=None, help="Display name (defaults to repo name)")
@click.option("--name", "-n", default=None, help="Display name (defaults to repo name or hostname)")
@click.option("--branch", "-b", default="main", show_default=True, help="Branch to use")
@click.option("--host", default=None, help="Git host FQDN (default: github.com)")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def add(repo, name, branch, host, verbose):
"""Register a marketplace from OWNER/REPO or HOST/OWNER/REPO."""
"""Register a marketplace from OWNER/REPO, HOST/OWNER/REPO, or HTTPS URL."""
logger = CommandLogger("marketplace-add", verbose=verbose)
try:
import re

from ..marketplace.client import _auto_detect_path, fetch_marketplace
from ..marketplace.models import MarketplaceSource
from ..marketplace.registry import add_marketplace

# URL-based path (Agent Skills discovery)
repo_lower = repo.lower()
if repo_lower.startswith("https://") or repo_lower.startswith("http://"):
if repo_lower.startswith("http://"):
logger.error(
"URL marketplaces must use HTTPS. "
"Please provide an https:// URL."
)
sys.exit(1)

resolved_url = _resolve_index_url(repo)
parsed = urlparse(resolved_url)
display_name = name or parsed.netloc

if not re.match(r"^[a-zA-Z0-9._-]+$", display_name):
logger.error(
f"Invalid marketplace name: '{display_name}'. "
f"Names must only contain letters, digits, '.', '_', and '-' "
f"(required for 'apm install skill@marketplace' syntax)."
)
sys.exit(1)

logger.start(f"Registering marketplace '{display_name}'...", symbol="gear")
logger.verbose_detail(f" URL: {resolved_url}")

source = MarketplaceSource(
name=display_name,
source_type="url",
url=resolved_url,
)

manifest = fetch_marketplace(source, force_refresh=True)
skill_count = len(manifest.plugins)

add_marketplace(source)

logger.success(
f"Marketplace '{display_name}' registered ({skill_count} skills)",
symbol="check",
)
if manifest.description:
logger.verbose_detail(f" {manifest.description}")
return

# GitHub path (OWNER/REPO or HOST/OWNER/REPO)

# Parse OWNER/REPO or HOST/OWNER/REPO
if "/" not in repo:
logger.error(
Expand Down Expand Up @@ -86,8 +164,6 @@ def add(repo, name, branch, host, verbose):
display_name = name or repo_name

# Validate name is identifier-compatible for NAME@MARKETPLACE syntax
import re

if not re.match(r"^[a-zA-Z0-9._-]+$", display_name):
logger.error(
f"Invalid marketplace name: '{display_name}'. "
Expand All @@ -102,7 +178,6 @@ def add(repo, name, branch, host, verbose):
if resolved_host != "github.com":
logger.verbose_detail(f" Host: {resolved_host}")

# Auto-detect marketplace.json location
probe_source = MarketplaceSource(
name=display_name,
owner=owner,
Expand All @@ -122,7 +197,6 @@ def add(repo, name, branch, host, verbose):

logger.verbose_detail(f" Detected path: {detected_path}")

# Create source with detected path
source = MarketplaceSource(
name=display_name,
owner=owner,
Expand All @@ -132,11 +206,9 @@ def add(repo, name, branch, host, verbose):
path=detected_path,
)

# Fetch and validate
manifest = fetch_marketplace(source, force_refresh=True)
plugin_count = len(manifest.plugins)

# Register
add_marketplace(source)

logger.success(
Expand All @@ -147,7 +219,14 @@ def add(repo, name, branch, host, verbose):
logger.verbose_detail(f" {manifest.description}")

except Exception as e:
logger.error(f"Failed to register marketplace: {e}")
from ..marketplace.errors import MarketplaceFetchError

if isinstance(e, MarketplaceFetchError):
logger.error(str(e))
elif isinstance(e, ValueError):
logger.error(f"Invalid index format: {e}")
else:
logger.error(f"Failed to register marketplace: {e}")
sys.exit(1)


Expand Down Expand Up @@ -181,7 +260,8 @@ def list_cmd(verbose):
f"{len(sources)} marketplace(s) registered:", symbol="info"
)
for s in sources:
click.echo(f" {s.name} ({s.owner}/{s.repo})")
location = s.url if s.is_url_source else f"{s.owner}/{s.repo}"
click.echo(f" {s.name} ({location})")
return

from rich.table import Table
Expand All @@ -198,7 +278,10 @@ def list_cmd(verbose):
table.add_column("Path", style="dim")

for s in sources:
table.add_row(s.name, f"{s.owner}/{s.repo}", s.branch, s.path)
if s.is_url_source:
table.add_row(s.name, s.url, "--", "--")
else:
table.add_row(s.name, f"{s.owner}/{s.repo}", s.branch, s.path)

console.print()
console.print(table)
Expand Down Expand Up @@ -299,7 +382,7 @@ def update(name, verbose):
if name:
source = get_marketplace_by_name(name)
logger.start(f"Refreshing marketplace '{name}'...", symbol="gear")
clear_marketplace_cache(name, host=source.host)
clear_marketplace_cache(source=source)
manifest = fetch_marketplace(source, force_refresh=True)
logger.success(
f"Marketplace '{name}' updated ({len(manifest.plugins)} plugins)",
Expand All @@ -317,7 +400,7 @@ def update(name, verbose):
)
for s in sources:
try:
clear_marketplace_cache(s.name, host=s.host)
clear_marketplace_cache(source=s)
manifest = fetch_marketplace(s, force_refresh=True)
logger.tree_item(
f" {s.name} ({len(manifest.plugins)} plugins)"
Expand Down Expand Up @@ -351,16 +434,17 @@ def remove(name, yes, verbose):
source = get_marketplace_by_name(name)

if not yes:
location = source.url if source.is_url_source else f"{source.owner}/{source.repo}"
confirmed = click.confirm(
f"Remove marketplace '{source.name}' ({source.owner}/{source.repo})?",
f"Remove marketplace '{source.name}' ({location})?",
default=False,
)
if not confirmed:
logger.progress("Cancelled", symbol="info")
return

remove_marketplace(name)
clear_marketplace_cache(name, host=source.host)
clear_marketplace_cache(source=source)
logger.success(f"Marketplace '{name}' removed", symbol="check")

except Exception as e:
Expand Down
8 changes: 8 additions & 0 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class LockedDependency:
is_dev: bool = False # True for devDependencies
discovered_via: Optional[str] = None # Marketplace name (provenance)
marketplace_plugin_name: Optional[str] = None # Plugin name in marketplace
source_url: Optional[str] = None # URL the index was fetched from
source_digest: Optional[str] = None # sha256 digest of the fetched index

def get_unique_key(self) -> str:
"""Returns unique key for this dependency."""
Expand Down Expand Up @@ -84,6 +86,10 @@ def to_dict(self) -> Dict[str, Any]:
result["discovered_via"] = self.discovered_via
if self.marketplace_plugin_name:
result["marketplace_plugin_name"] = self.marketplace_plugin_name
if self.source_url is not None:
result["source_url"] = self.source_url
if self.source_digest is not None:
result["source_digest"] = self.source_digest
return result

@classmethod
Expand Down Expand Up @@ -122,6 +128,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency":
is_dev=data.get("is_dev", False),
discovered_via=data.get("discovered_via"),
marketplace_plugin_name=data.get("marketplace_plugin_name"),
source_url=data.get("source_url"),
source_digest=data.get("source_digest"),
)

@classmethod
Expand Down
Loading