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
7 changes: 7 additions & 0 deletions .apm/architecture/owners/marketplace-plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"version": 1,
"owners": [
{
"id": "local-authoring-catalog",
"decision": "Live local authoring catalog interpretation without generation",
"owner": "marketplace/authoring_source.py coordinates yml_schema, MarketplaceBuilder and resolver path authority",
"selectors": ["src/apm_cli/marketplace/authoring_source.py", "src/apm_cli/marketplace/client.py"],
"guards": ["marketplace-integrations-authoring-source"]
},
{
"id": "marketplace-locked-selection",
"decision": "Marketplace requested-ref consistency against locked selection evidence",
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/consumer/installing-from-marketplaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ Most read `.claude-plugin/marketplace.json` (Anthropic-compatible
schema); Codex reads `.agents/plugins/marketplace.json`. Producers
who enable both outputs reach every runtime from one repo.

## Local authoring previews through APM

Register an explicitly selected local authoring file with
`apm marketplace add ./apm.yml --name alice-dev`, then browse it with
`apm marketplace browse alice-dev` or select a package with
`apm install my-tool@alice-dev`. APM rereads the file on later explicit
operations; registration does not install its inventory or publish anything.
Relative package sources resolve from the file's directory. Remote entries
install through their actual dependency source and existing lock machinery;
locked replay does not require the author's preview file. Local entries still
require their local directories. See the [marketplace CLI reference](../../reference/cli/marketplace/)
for validation and offline behavior.

## Codex compatibility bundles through APM

APM recognizes a compatibility-only `.codex-plugin/plugin.json` without
Expand Down
18 changes: 18 additions & 0 deletions docs/src/content/docs/producer/publish-to-a-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ your repo with `apm marketplace add`. This page covers the
authoring surface: the registry schema and the `apm marketplace`
verbs.

For a local preview, register the authoring file directly instead of generating
JSON first:

```bash
apm marketplace add ./apm.yml --name alice-dev
apm marketplace browse alice-dev
```

This registers a live local discovery source. Later explicit operations reread
the file, resolving relative package paths from its directory. Registration
does not build, install inventory, execute hooks or publish packages. Use
`--offline` with `add` or `browse` for local entries or remote entries pinned to
full commit SHAs; remote tags and ranges need fresh resolution in a new CLI
invocation. Continue using `apm pack`
for native generated outputs and publication. A local package path is not a
portable distribution source. See the [CLI reference](../../reference/cli/marketplace/)
for resolution and locked replay semantics.

For the operational concerns that surround a marketplace, see:

- [Repo shapes](../repo-shapes/) -- single-plugin, aggregator, and
Expand Down
44 changes: 41 additions & 3 deletions docs/src/content/docs/reference/cli/marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ validating it, and preparing artifacts for consumer repositories).

```bash
# Consume
apm marketplace add SOURCE [--name N] [--ref R | --branch B] [--host FQDN]
apm marketplace add SOURCE [--name N] [--ref R | --branch B] [--host FQDN] [--offline]
apm marketplace list
apm marketplace browse NAME
apm marketplace browse NAME [--offline]
apm marketplace update [NAME]
apm marketplace remove NAME
apm marketplace validate NAME
Expand Down Expand Up @@ -71,9 +71,47 @@ Register a marketplace from a source reference. Accepted forms:
select a branch, tag, or commit.
- Local filesystem path -- absolute (`/srv/marketplaces/agent-forge`),
relative (`./local-mkt`), home-based (`~/code/marketplace`), or a
direct `marketplace.json` file.
direct `marketplace.json` file, or an explicitly selected local `apm.yml`.
- `file://` URI -- `file:///srv/marketplaces/agent-forge.git`.

#### Live local authoring catalogs

```bash
apm marketplace add ./apm.yml --name alice-dev
apm marketplace browse alice-dev
apm view my-tool@alice-dev
apm install my-tool@alice-dev
```

The selected file must contain a valid `marketplace:` declaration. APM uses
the existing authoring parser, ref resolver and catalog composer in memory;
it does not run `apm pack` or write generated catalogs. Relative package
sources are anchored to the authoring file's directory, with the existing
path containment checks. Native `metadata.pluginRoot` layout hints do not
rebase authored package paths. Each invocation reads one configuration
snapshot; later explicit browse, view, update or selected-install operations
reread the file. A failed registration leaves an existing registration unchanged.

Registration and browsing do not install inventory, activate plugins, execute
package hooks or publish packages. Remote tags and version ranges may require
Git network access to resolve. Cosmetic remote package metadata is not fetched.
For this local YAML workflow, `add --offline` and `browse --offline` use the
existing cached-ref resolver: local entries and explicit commit SHAs work
without network; an uncached tag or range fails with an actionable error.
Ref caches are in memory for one invocation; an earlier online registration
does not make tags or ranges available in a later offline invocation. Pin
remote entries to full commit SHAs for an offline authoring preview.
These flags do not add offline support to JSON, Git or HTTPS catalog sources.

Installing a remote entry records its actual package dependency and existing
locked resolution. Locked replay does not require the preview file or its
registration. Local entries remain local filesystem dependencies: their
paths and hashes do not distribute files to another machine. Publishing and
selecting a distributed source remain explicit operations. Remote YAML
catalog URLs, automatic watching and automatic source promotion are unsupported.

Generated JSON catalogs and repository registration continue to work as before.

```bash
# GitHub shorthand
apm marketplace add my-org/awesome-agents
Expand Down
14 changes: 13 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set
|---------|---------|-----------|
| `apm marketplace add SOURCE` | Register a marketplace. `SOURCE` accepts `OWNER/REPO`, `HOST/OWNER/REPO`, nested `HOST/group/sub/.../REPO`, HTTPS git URL with optional `#ref`, hosted `marketplace.json` URL, SSH URL (`git@host:org/repo.git` or `ssh://git@host[:PORT]/org/repo.git`), local directory or file path, or `file://` URI. `--ref` applies only to git-backed sources; `--host` applies only to shorthand sources. | `-n NAME`, `-r REF`, `--host HOST` |
| `apm marketplace list` | List registered marketplaces | -- |
| `apm marketplace browse NAME` | Browse marketplace plugins | -- |
| `apm marketplace browse NAME` | Browse marketplace plugins; reread live local `apm.yml` catalogs | `--offline` (local YAML only) |
| `apm marketplace update [NAME]` | Update marketplace index | -- |
| `apm marketplace remove NAME` | Remove a marketplace | `-y` skip confirm |
| `apm marketplace validate NAME` | Validate raw structure, plugin schema, and duplicate plugin names. Add accepts unsupported or malformed entries with a warning; validate reports paths and exits 1 without rewriting the source manifest or registered marketplace entry. | `-v` |
Expand All @@ -665,6 +665,18 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set

## Marketplace authoring

For a live local preview, run `apm marketplace add ./apm.yml --name alice-dev`.
The file must contain a valid `marketplace:` declaration. Browse, view, update
and selected installs reread it on later invocations; relative package paths
resolve from the file's directory. Registration neither installs inventory nor
executes hooks, publishes packages or writes generated catalogs. `add --offline`
and `browse --offline` are limited to local YAML: local entries and commit SHAs
need no network, while uncached remote tags/ranges fail. Ref caches are not
persisted across CLI invocations. Remote entries install
through their actual dependency source and existing lock resolution, so locked
replay does not require the preview. Local directory dependencies remain local.
Native generated JSON and repository registration retain their existing flow.

> Source of truth is the `marketplace:` block in `apm.yml`. `apm pack` produces `.claude-plugin/marketplace.json` whenever that block is present. The legacy standalone `marketplace.yml` is deprecated -- use `apm marketplace migrate` to fold it in.

| Command | Purpose | Key flags |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,57 @@ def _scan_agent_plugin_duplicates(
return tuple(findings)


def _check_authoring_source(provider: FactsProvider) -> tuple[Violation, ...]:
"""Keep direct YAML consumption on canonical parsing and composition."""
inv = frozenset(provider.inventory)
rule_id = "marketplace-integrations-authoring-source"
path = "src/apm_cli/marketplace/authoring_source.py"
findings = list(
_require_subs(
provider,
inv,
rule_id,
path,
(
"load_marketplace_from_apm_yml(path)",
"MarketplaceBuilder.from_config(",
"builder.resolve()",
"builder.compose_discovery_catalog(",
"resolve_local_plugin_path(",
),
"Local authoring catalogs must reuse canonical parser, resolver and composer",
)
)
facts, failures = checked_facts(provider, path, rule_id, require_python=True)
findings.extend(failures)
for call in facts.calls:
if call.qualname.rsplit(".", 1)[-1] in {
"build",
"write_output",
"write_text",
"write_bytes",
"safe_load",
"load_yaml_str",
}:
findings.append(
violation(
rule_id,
path,
"Authoring catalog reads must not parse YAML independently or invoke writing",
line=call.line,
)
)
return tuple(findings)


RULES: tuple[Rule, ...] = (
Rule(
id="marketplace-integrations-authoring-source",
group=GROUP,
guard_ids=("marketplace-integrations-authoring-source",),
description="Live local authoring catalogs reuse canonical owners without writing.",
check=_check_authoring_source,
),
Rule(
id=_RID_CATALOG,
group=GROUP,
Expand Down
43 changes: 36 additions & 7 deletions src/apm_cli/commands/marketplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def _marketplace_add_unsupported_host_error(
\b
Examples:
apm marketplace add owner/repo
apm marketplace add ./apm.yml --name alice-dev
apm marketplace add github.com/owner/repo
apm marketplace add https://github.com/owner/repo#v1.0.0
apm marketplace add https://catalog.example.com/marketplace.json --name catalog
Expand Down Expand Up @@ -325,20 +326,30 @@ def _marketplace_add_unsupported_host_error(
help="Git host FQDN for OWNER/REPO shorthand (default: github.com)",
)
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
@click.option(
"--offline",
is_flag=True,
help="Read local apm.yml without network (remote entries need commit SHAs)",
)
@serialized_lifecycle
def add(source, name, ref, branch, host, verbose):
def add(source, name, ref, branch, host, verbose, offline=False):
"""Register a marketplace.

SOURCE accepts: OWNER/REPO shorthand, HOST/OWNER/REPO shorthand, a full
HTTPS git URL with optional ``#ref`` (GitHub, GitLab, Azure DevOps,
Gitea, Bitbucket Server, or any self-hosted git server), a hosted
``marketplace.json`` URL, an SSH URL (``git@host:org/repo.git`` or
``ssh://git@host:2222/org/repo.git``),
a local filesystem path, or a ``file://`` URI.
a local filesystem path (including an explicitly selected ``apm.yml``),
or a ``file://`` URI.
"""
logger = CommandLogger("marketplace-add", verbose=verbose)
try:
from ...marketplace.client import _auto_detect_path, fetch_marketplace
from ...marketplace.client import (
_auto_detect_path,
fetch_marketplace,
is_local_authoring_source,
)
from ...marketplace.models import MarketplaceSource
from ...marketplace.registry import add_marketplace
from ...marketplace.source_identity import parse_marketplace_source
Expand Down Expand Up @@ -441,6 +452,12 @@ def add(source, name, ref, branch, host, verbose):
ref="" if is_direct_url else effective_ref,
path="" if is_direct_url else "marketplace.json",
)
if offline and not is_local_authoring_source(probe_source):
logger.error(
"--offline requires an explicitly selected local apm.yml. "
"Omit --offline to register a JSON or repository source."
)
sys.exit(1)
if is_direct_url or _local_source_points_to_file(probe_source):
detected_path = ""
else:
Expand All @@ -461,7 +478,9 @@ def add(source, name, ref, branch, host, verbose):
ref="" if is_direct_url else effective_ref,
path=detected_path,
)
manifest = fetch_marketplace(fetch_source, force_refresh=True)
manifest = fetch_marketplace(
fetch_source, force_refresh=True, **({"offline": True} if offline else {})
)
plugin_count = len(manifest.plugins)

manifest_name = (manifest.name or "").strip()
Expand Down Expand Up @@ -583,8 +602,11 @@ def _local_source_points_to_file(source: MarketplaceSource) -> bool:
"""Return True when a local marketplace source points directly to a file."""
if source.kind != "local":
return False
from ...marketplace.client import is_local_authoring_source

try:
return Path(source.local_path).expanduser().is_file()
path = Path(source.local_path).expanduser()
return path.is_file() or is_local_authoring_source(source)
except OSError:
return False

Expand Down Expand Up @@ -700,7 +722,12 @@ def list_cmd(verbose):
@marketplace.command(help="Browse plugins in a marketplace")
@click.argument("name", required=True)
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def browse(name, verbose):
@click.option(
"--offline",
is_flag=True,
help="Read local apm.yml without network (remote entries need commit SHAs)",
)
def browse(name, verbose, offline=False):
"""Show available plugins in a marketplace."""
logger = CommandLogger("marketplace-browse", verbose=verbose)
try:
Expand All @@ -710,7 +737,9 @@ def browse(name, verbose):
source = get_marketplace_by_name(name)
logger.start(f"Fetching plugins from '{name}'...", symbol="search")

manifest = fetch_marketplace(source, force_refresh=True)
manifest = fetch_marketplace(
source, force_refresh=True, **({"offline": True} if offline else {})
)

if not manifest.plugins:
logger.warning(f"Marketplace '{name}' has no plugins")
Expand Down
71 changes: 71 additions & 0 deletions src/apm_cli/marketplace/authoring_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Read an explicitly selected local authoring catalog without producing files."""

from __future__ import annotations

from pathlib import Path

import click

from .builder import BuildOptions, MarketplaceBuilder
from .errors import MarketplaceError, MarketplaceFetchError, OfflineMissError
from .models import MarketplaceSource, parse_marketplace_json
from .resolver import resolve_local_plugin_path
from .yml_schema import load_marketplace_from_apm_yml


def load_authoring_catalog(
source: MarketplaceSource,
*,
offline: bool = False,
auth_resolver: object | None = None,
) -> dict:
"""Resolve and compose one snapshot per Click invocation, rereading next time.

Only the user-selected local ``apm.yml`` enters this path. Native output
mappers still own the catalog shape; no packing or writing is involved.
Remote refs use the normal builder resolver. Cosmetic remote metadata is
deliberately omitted so browsing does not download every package manifest.
"""
path = Path(source.local_path).expanduser().absolute()
ctx = click.get_current_context(silent=True)
snapshots = ctx.find_root().meta.setdefault(__name__, {}) if ctx else {}
key = (str(path), offline)
if key in snapshots:
return snapshots[key]

try:
config = load_marketplace_from_apm_yml(path)
builder = MarketplaceBuilder.from_config(
config,
path.parent,
options=BuildOptions(offline=offline),
auth_resolver=auth_resolver,
)
# Authored paths are relative to the file. Validate them through the
# existing containment owner before resolving or composing the catalog.
for entry in config.packages:
if entry.is_local:
resolve_local_plugin_path(entry.source, source)
resolved = builder.resolve()
document = builder.compose_discovery_catalog(resolved.entries)
manifest = parse_marketplace_json(document, source.name)
# Validate the composed consumer interpretation through the same owner;
# native output layout hints must not change these author-relative paths.
for plugin in manifest.plugins:
if isinstance(plugin.source, str):
resolve_local_plugin_path(plugin.source, source, manifest.plugin_root)
if manifest.structural_errors:
raise ValueError("; ".join(manifest.structural_errors))
except (MarketplaceError, OSError, ValueError) as exc:
retry_hint = (
"Retry without --offline to resolve remote refs, or pin remote entries to full commit SHAs."
if isinstance(exc, OfflineMissError)
else "Check the marketplace section, package paths and refs in that file, then retry."
)
raise MarketplaceFetchError(
source.name,
f"cannot load marketplace declaration from {path}: {exc}",
retry_hint=retry_hint,
) from exc
snapshots[key] = document
return document
Loading
Loading