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
53 changes: 42 additions & 11 deletions docs/src/content/docs/producer/publish-to-a-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ description: Author an APM marketplace registry, build it, and publish updates t

A **marketplace** in APM is a curated index of packages that one repo
publishes and many repos install from. You author it as a
`marketplace:` block in `apm.yml`, build it into
`.claude-plugin/marketplace.json` with `apm pack`, and let consumers
register your repo with `apm marketplace add`. This page walks the
`marketplace:` block in `apm.yml`, build it into one or more
marketplace artifacts with `apm pack`, and let consumers register
your repo with `apm marketplace add`. This page walks the
producer side: schema, build, and the optional `apm marketplace
publish` flow that opens PRs against pinned consumer repos.

Expand All @@ -20,7 +20,7 @@ packages from it -- see [Marketplaces](../../guides/marketplaces/).
apm marketplace init # 1. add the block to apm.yml
$EDITOR apm.yml # 2. describe each package
apm marketplace check # 3. validate refs resolve
apm pack # 4. compile marketplace.json
apm pack # 4. compile marketplace artifacts
git add apm.yml .claude-plugin/marketplace.json
git commit -m "Release v1.0.0" && git push # 5. ship
```
Expand All @@ -40,9 +40,11 @@ APM uses a single source-of-truth model:

- `apm.yml` -- hand-edited. The `marketplace:` block declares your
registry: owner, packages, version ranges.
- `.claude-plugin/marketplace.json` -- generated by `apm pack`.
- `.claude-plugin/marketplace.json` -- generated by `apm pack` by default.
Byte-compatible with [Anthropic's marketplace.json](https://docs.claude.com/en/docs/claude-code/plugin-marketplaces)
so Claude Code, Copilot CLI, and APM all read the same artefact.
- `.agents/plugins/marketplace.json` -- optional Codex repo marketplace
output. Enable it by adding `codex` to `marketplace.outputs`.

Both files are committed. The legacy standalone `marketplace.yml` is
deprecated; if you still have one, run `apm marketplace migrate`.
Expand All @@ -68,6 +70,14 @@ marketplace:
name: acme-org
url: https://github.com/acme-org

outputs: [claude] # default; add codex for Codex repo output

claude:
output: .claude-plugin/marketplace.json

codex:
output: .agents/plugins/marketplace.json

build:
tagPattern: "v{version}" # default; per-package overridable

Expand All @@ -84,6 +94,7 @@ marketplace:
- name: local-tool
source: ./packages/local-tool # ships from this same repo
version: 0.1.0
category: Productivity # required when outputs includes codex
```

The key in `apm.yml` is `packages:`. It becomes `plugins:` in the
Expand All @@ -93,8 +104,26 @@ error, never silently ignored.

For the full field reference (every key on every entry, including
`subdir`, `tag_pattern`, `include_prerelease`, `metadata`,
`pluginRoot`, and Anthropic pass-through fields like `tags`, `author`,
`license`), see [Marketplace authoring](../../guides/marketplace-authoring/).
`pluginRoot`, `outputs`, `claude`, `codex`, and pass-through fields
like `tags`, `author`, `license`), see the reference below.

Marketplace output targets use a selector-list pattern:

```yaml
marketplace:
outputs: [claude, codex]
```

Claude output is selected by default for backwards compatibility. The
legacy `marketplace.output` field remains supported as shorthand for
`marketplace.claude.output`; when both are set, the explicit
`claude.output` value wins. The `--marketplace-output` flag overrides
only the Claude/Anthropic output path. Codex output always uses
`marketplace.codex.output` or its default.

When `codex` is selected, every package must define `category`. Codex
output maps local entries to `source: local`, remote entries to
`source: url`, and remote subdirectory entries to `source: git-subdir`.

## Build

Expand All @@ -103,8 +132,8 @@ apm pack
```

`apm pack` resolves every remote `packages:` entry against
`git ls-remote`, leaves local-path entries untouched, and writes
`.claude-plugin/marketplace.json` atomically. Useful flags:
`git ls-remote`, leaves local-path entries untouched, and writes each
selected marketplace output atomically. Useful flags:

```bash
apm pack --dry-run # resolve and print; do not write
Expand Down Expand Up @@ -173,9 +202,11 @@ consumers running `apm install --update` on their own cadence.
- **Both `apm.yml` (`marketplace:` block) and `marketplace.yml`
present** is a hard error. Pick one; prefer the block and run
`apm marketplace migrate` to consolidate.
- **`*.json` in `.gitignore`** will silently skip the generated file.
- **`*.json` in `.gitignore`** will silently skip generated files.
`apm marketplace init` warns on this; if you hit it, add an
unignore: `!.claude-plugin/marketplace.json`.
unignore for every enabled output, such as
`!.claude-plugin/marketplace.json` and
`!.agents/plugins/marketplace.json`.
- **Local-path entries skip git resolution.** They emit the path
verbatim; consumers see the same path. Use `metadata.pluginRoot` if
your plugins live under a common subdirectory.
Expand Down
17 changes: 10 additions & 7 deletions docs/src/content/docs/reference/cli/pack.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: apm pack
description: Pack distributable artifacts (plugin bundle, APM bundle, or marketplace.json) from your APM project.
description: Pack distributable artifacts (plugin bundle, APM bundle, or marketplace artifacts) from your APM project.
sidebar:
order: 17
---
Expand All @@ -16,8 +16,8 @@ apm pack [OPTIONS]
`apm pack` produces distributable artifacts from the current APM project. It reads `apm.yml` to decide what to emit:

- `dependencies:` block present -> a bundle (directory or `.tar.gz`).
- `marketplace:` block present -> `.claude-plugin/marketplace.json`.
- Both blocks present -> both artifacts in a single run.
- `marketplace:` block present -> selected marketplace artifacts.
- Both blocks present -> bundle plus selected marketplace artifacts in a single run.

The bundle is built from `apm.lock.yaml`. An enriched copy of the lockfile (per-file SHA-256 in `bundle_files`, plus `pack:` metadata) is embedded inside the bundle so `apm install <bundle>` can verify integrity at install time.

Expand All @@ -35,7 +35,7 @@ Bundles are target-agnostic. The consumer's project decides where files land at
| `--verbose`, `-v` | off | Show per-file paths and detailed packer output. |
| `--offline` | off | Marketplace: resolve version ranges from cached refs only; skip `git ls-remote`. |
| `--include-prerelease` | off | Marketplace: allow pre-release tags to satisfy version ranges. |
| `--marketplace-output PATH` | `.claude-plugin/marketplace.json` | Marketplace: override the output path. |
| `--marketplace-output PATH` | `.claude-plugin/marketplace.json` | Marketplace: override the Claude/Anthropic output path. |
| `--legacy-skill-paths` | off | Bundle skills under per-client paths (e.g. `.cursor/skills/`) instead of the converged `.agents/skills/`. Compatibility flag. |
| `--target`, `-t VALUE` | auto-detect | **Deprecated.** Recorded as informational `pack.target` metadata only; ignored by `apm install`. Will be removed in a future release. |

Expand Down Expand Up @@ -63,7 +63,7 @@ apm pack
apm pack --archive --offline
```

### Override marketplace output path
### Override Claude marketplace output path

```bash
apm pack --marketplace-output ./build/marketplace.json
Expand Down Expand Up @@ -106,9 +106,11 @@ dependencies:
- repo_url: owner/repo
```

### Marketplace artifact
### Marketplace artifacts

`.claude-plugin/marketplace.json` (or `--marketplace-output PATH`). Each remote plugin's version range is resolved against `git ls-remote`; local-path entries pass through verbatim. The file is written atomically. `.claude-plugin/` is created if absent; nothing else is scaffolded there.
`.claude-plugin/marketplace.json` by default, plus any additional artifact selected by `marketplace.outputs` such as `.agents/plugins/marketplace.json` for Codex. Each remote plugin's version range is resolved against `git ls-remote`; local-path entries pass through verbatim. Files are written atomically, and parent directories are created if absent.

`--marketplace-output PATH` is a Claude/Anthropic compatibility flag. It overrides only the Claude artifact path; Codex output uses `marketplace.codex.output` or its default.

## Behavior

Expand All @@ -117,6 +119,7 @@ dependencies:
- **Empty bundle warning.** If no files match (e.g. nothing was installed yet), `apm pack` emits a warning and exits `0` with an empty bundle. Verbose mode prints a hint to run `apm install` first.
- **Share line.** On success, `apm pack` prints `Share with: apm install <bundle-path>` so the produced bundle is immediately copy-pasteable.
- **Marketplace fallback.** With no `marketplace:` block in `apm.yml`, a legacy `marketplace.yml` file is read with a deprecation warning. Both files present is a hard error.
- **Marketplace outputs.** `marketplace.outputs` defaults to `[claude]`. Add `codex` to also write `.agents/plugins/marketplace.json`; when selected, each package must define `category`.

## Exit codes

Expand Down
16 changes: 12 additions & 4 deletions src/apm_cli/commands/marketplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def marketplace(ctx):


def _check_gitignore_for_marketplace_json(logger):
"""Warn if .gitignore contains a rule that would ignore marketplace.json."""
"""Warn if .gitignore contains a rule that would ignore marketplace outputs."""
gitignore_path = Path.cwd() / ".gitignore"
if not gitignore_path.exists():
return
Expand All @@ -221,7 +221,14 @@ def _check_gitignore_for_marketplace_json(logger):
except OSError:
return

patterns = {"marketplace.json", "**/marketplace.json", "/marketplace.json", "*.json"}
patterns = {
"marketplace.json",
"**/marketplace.json",
"/marketplace.json",
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
"*.json",
}
for line in lines:
stripped = line.strip()
# Skip blank and commented lines
Expand All @@ -230,8 +237,9 @@ def _check_gitignore_for_marketplace_json(logger):
if stripped in patterns:
logger.warning(
"Your .gitignore ignores marketplace.json. "
"Both apm.yml and the generated marketplace.json must be "
"tracked in git. Remove the .gitignore rule.",
"Track apm.yml plus generated marketplace files such as "
".claude-plugin/marketplace.json and .agents/plugins/marketplace.json. "
"Remove the .gitignore rule or add explicit unignore entries.",
symbol="warning",
)
return
Expand Down
3 changes: 2 additions & 1 deletion src/apm_cli/commands/marketplace/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ def init(force, no_gitignore_check, name, owner, verbose):
next_steps = [
"Edit the 'marketplace:' block in apm.yml to add your packages",
"Run 'apm pack' to generate .claude-plugin/marketplace.json",
"Commit BOTH apm.yml and the generated marketplace.json",
"Add 'codex' to marketplace.outputs to also generate .agents/plugins/marketplace.json",
"Commit apm.yml and the generated marketplace file(s)",
]

try:
Expand Down
41 changes: 25 additions & 16 deletions src/apm_cli/commands/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
Reads apm.yml to decide what to produce:

dependencies: block -> bundle (directory or .tar.gz)
marketplace: block -> .claude-plugin/marketplace.json
both blocks present -> both artifacts
marketplace: block -> selected marketplace artifacts
both blocks present -> bundle plus selected marketplace artifacts

The lockfile (apm.lock.yaml) pins bundle contents. An enriched copy
is embedded in each bundle.
Expand All @@ -42,7 +42,7 @@
apm pack
apm pack --archive --offline

# Override marketplace.json location:
# Override Claude marketplace.json location:
apm pack --marketplace-output ./build/marketplace.json

Exit codes:
Expand Down Expand Up @@ -104,7 +104,10 @@
"marketplace_output",
type=click.Path(),
default=None,
help="Marketplace: override output path (default: .claude-plugin/marketplace.json).",
help=(
"Marketplace: override Claude/Anthropic output path "
"(default: .claude-plugin/marketplace.json)."
),
)
@click.option(
"--legacy-skill-paths",
Expand Down Expand Up @@ -180,7 +183,7 @@ def pack_cmd(
if sub.kind is OutputKind.BUNDLE:
_render_bundle_result(logger, sub.payload, fmt, target, dry_run)
elif sub.kind is OutputKind.MARKETPLACE:
_render_marketplace_result(logger, sub.payload, dry_run, sub.warnings)
_render_marketplace_result(logger, sub.payload, dry_run, sub.warnings, sub.outputs)


def _render_bundle_result(logger, pack_result, fmt, target, dry_run):
Expand Down Expand Up @@ -232,23 +235,29 @@ def _render_bundle_result(logger, pack_result, fmt, target, dry_run):
logger.info(f"Share with: apm install {pack_result.bundle_path}")


def _render_marketplace_result(logger, report, dry_run, extra_warnings=None):
def _render_marketplace_result(logger, report, dry_run, extra_warnings=None, outputs=None):
"""Render the marketplace producer's report (one-liner summary)."""
if report is None:
return
for warn_msg in extra_warnings or []:
logger.warning(warn_msg)
output_reports = tuple(getattr(report, "outputs", ()) or ())
if not output_reports:
for output in outputs or []:
if dry_run:
logger.dry_run_notice(f"Would write marketplace.json -> {output}")
else:
logger.success(f"Built marketplace.json -> {output}")
return
for warn_msg in report.warnings:
logger.warning(warn_msg)
Comment on lines +238 to 251
if dry_run or report.dry_run:
logger.dry_run_notice(
f"Would write marketplace.json ({len(report.resolved)} package(s)) "
f"-> {report.output_path}"
for output_report in output_reports:
message = (
f"marketplace.json [{output_report.profile}] "
f"({len(output_report.resolved)} package(s)) -> {output_report.output_path}"
)
return
logger.success(
f"Built marketplace.json ({len(report.resolved)} package(s)) -> {report.output_path}"
)
if dry_run or output_report.dry_run:
logger.dry_run_notice(f"Would write {message}")
else:
logger.success(f"Built {message}")


@click.command(
Expand Down
54 changes: 36 additions & 18 deletions src/apm_cli/core/build_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def produce(self, options: BuildOptions, logger: Any) -> ProducerResult:
from ..marketplace.builder import (
BuildOptions as MktBuildOptions,
)
from ..marketplace.builder import BuildReport as MarketplaceBuildReport
from ..marketplace.builder import (
MarketplaceBuilder,
)
Expand All @@ -143,6 +144,7 @@ def produce(self, options: BuildOptions, logger: Any) -> ProducerResult:
detect_config_source,
load_marketplace_config,
)
from ..marketplace.output_profiles import MARKETPLACE_OUTPUTS
from ..marketplace.yml_schema import MarketplaceYmlError

warnings: list[str] = []
Expand All @@ -164,19 +166,11 @@ def _warn(msg: str) -> None:
else:
yml_for_builder = project_root / "apm.yml"

# Determine the output override: explicit flag wins; otherwise
# legacy marketplace.yml keeps writing to ./marketplace.json (the
# value baked into the legacy config), and apm.yml keeps writing
# to .claude-plugin/marketplace.json (also the config default).
output_override: Path | None = None
if options.marketplace_output is not None:
output_override = options.marketplace_output

mkt_opts = MktBuildOptions(
dry_run=options.dry_run,
offline=options.marketplace_offline,
include_prerelease=options.marketplace_include_prerelease,
output_override=output_override,
marketplace_output=None,
)
builder = MarketplaceBuilder.from_config(
config, project_root=project_root, options=mkt_opts
Expand All @@ -185,20 +179,44 @@ def _warn(msg: str) -> None:
# exists so any downstream diagnostics report a real location.
builder._yml_path = yml_for_builder

try:
report = builder.build()
except MktBuildError as exc:
raise BuildError(str(exc)) from exc

resolve_result = None
output_reports = []
outputs: list[Path] = []
if report.output_path is not None:
outputs.append(Path(report.output_path))
warnings.extend(report.warnings)
for output_name in config.outputs:
profile = MARKETPLACE_OUTPUTS[output_name]
try:
if resolve_result is None:
resolve_result = builder.resolve()
resolved = resolve_result.entries

configured_output_value = getattr(config, profile.config_attr).output
configured_output = Path(configured_output_value)
output_path = project_root / configured_output
if profile.supports_cli_output_override and options.marketplace_output is not None:
output_path = options.marketplace_output

output_report = builder.write_output(
profile,
resolved,
output_path,
include_diff=True,
remote_metadata=builder.remote_metadata_for_profile(profile, resolved),
errors=resolve_result.errors,
)
output_reports.extend(output_report.outputs)
if output_report.output_path is not None:
outputs.append(Path(output_report.output_path))
except MktBuildError as exc:
raise BuildError(str(exc)) from exc

marketplace_report = MarketplaceBuildReport(outputs=tuple(output_reports))
warnings.extend(marketplace_report.warnings)

return ProducerResult(
kind=OutputKind.MARKETPLACE,
outputs=outputs,
warnings=warnings,
payload=report,
payload=marketplace_report,
)
Comment thread
henrydennis marked this conversation as resolved.


Expand Down
Loading
Loading