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

## [Unreleased]

### Added

- `apm pack` can emit a GitHub Copilot CLI marketplace output (`.github/plugin/marketplace.json`, selected via `marketplace.outputs: [copilot]`); its `plugins[].source` is always a relative-path string, never the Claude/Codex pin-preserving object shape. Documented in the publish-to-a-marketplace and `apm pack` reference guides. (#2600)

### Changed

- `docs/src/content/docs/specs/openapm-v0.1.md` adds proposed `req-tg-015` for Codex-native `model`/`model_reasoning_effort` preservation and bounded dropped-metadata diagnostics. (#3150)
Expand Down
26 changes: 23 additions & 3 deletions docs/src/content/docs/producer/publish-to-a-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ APM uses a single source-of-truth model:
- `.agents/plugins/marketplace.json` -- optional Codex repo
marketplace output. Enable it by adding `codex` to
`marketplace.outputs`.
- `.github/plugin/marketplace.json` -- optional Copilot CLI
marketplace output. Enable it by adding `copilot` to
`marketplace.outputs`.

Commit every generated file matching your enabled
`marketplace.outputs`. The legacy standalone `marketplace.yml` is
Expand Down Expand Up @@ -85,14 +88,17 @@ marketplace:
url: https://github.com/acme-org

outputs: # map form (recommended)
claude: {} # default; add codex for Codex output
claude: {} # default; add codex for Codex output, copilot for Copilot CLI output

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

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

copilot:
output: .github/plugin/marketplace.json

# Optional: package sources can be relative to this git base.
sourceBase: https://gitlab.corp.example.com/platform/agent-marketplace

Expand Down Expand Up @@ -215,6 +221,19 @@ remote entries to `source: url`, and remote subdirectory entries to
`source: git-subdir`. Claude output also emits `category` on any
package where it is set, even though only `codex` requires it.

The `copilot` output targets the GitHub Copilot CLI marketplace
schema (`.github/plugin/marketplace.json` by default). It has no
`category` requirement. Unlike Claude/Codex, its `plugins[].source`
is always a relative-path **string** -- never the
`{source, url, ref, sha}` pin-preserving object shape -- so resolved
git `ref`/`sha` pins are not carried in this output; only
`apm install` (which reads `apm.yml`/`apm.lock.yaml` directly)
reproduces the exact pin. Each output format also declares an
`APM_MARKETPLACE_<FORMAT>_PATH` environment variable name (for
example `APM_MARKETPLACE_COPILOT_PATH`) reserved for a future release
to override its output path; it is validated at startup but not yet
consumed by `apm pack`.

## Build

```bash
Expand Down Expand Up @@ -269,8 +288,9 @@ range exits non-zero before you push the release commit.
- **`*.json` in `.gitignore`** will silently skip generated files.
`apm marketplace init` warns on this; if you hit it, add an
unignore for every enabled output, such as
`!.claude-plugin/marketplace.json` and
`!.agents/plugins/marketplace.json`.
`!.claude-plugin/marketplace.json`,
`!.agents/plugins/marketplace.json`, and
`!.github/plugin/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
8 changes: 6 additions & 2 deletions docs/src/content/docs/reference/cli/pack.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ marketplace:
claude: {}
codex:
path: ./build/codex-marketplace.json
copilot:
path: ./build/copilot-marketplace.json
```

### Preview without writing
Expand Down Expand Up @@ -209,9 +211,11 @@ dependencies:

### Marketplace artifacts

`.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.
`.claude-plugin/marketplace.json` by default, plus any additional artifact selected by `marketplace.outputs` such as `.agents/plugins/marketplace.json` for Codex or `.github/plugin/marketplace.json` for Copilot CLI. 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.

Configure marketplace artifact paths in `apm.yml` with the `marketplace.outputs` map, keyed by format. Use `--marketplace-path FORMAT=PATH` to override per-format output paths at pack time.
Configure marketplace artifact paths in `apm.yml` with the `marketplace.outputs` map, keyed by format. Use `--marketplace-path FORMAT=PATH` to override per-format output paths at pack time. Each format also declares a reserved `APM_MARKETPLACE_<FORMAT>_PATH` environment variable name (for example `APM_MARKETPLACE_COPILOT_PATH`); it is validated at startup but not yet consumed by `apm pack`.

The Copilot output's `plugins[].source` is always a relative-path string (never the `{source, url, ref, sha}` object shape Claude/Codex use), so it carries no `ref`/`sha` pin -- only the path. It has no `category` requirement, unlike `codex`.

Remote Claude entries can inherit `description` and `version` from their own
`apm.yml`. If APM cannot fetch that metadata, normal packing writes the artifact
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set
| `apm marketplace outdated` | Report upgradable plugins, range-aware; respects `tag_pattern` and common monorepo tag layouts | `--offline`, `--include-prerelease`, `-v` |
| `apm marketplace check` | Validate the `marketplace:` block and verify refs resolve | `--offline`, `-v` |
| `apm marketplace audit NAME` | Supply-chain audit for plugin deps; local string sources are contained in the registered root | `--strict` (CI exit-1 on bypasses, skipped sources, verification errors, or no verified plugins), `-v` |
| `apm doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` to also publish for Codex consumers). The executable-trust row names malformed configuration under either `executables` or the deprecated `allowExecutables` key and reports local allows overridden by org policy. GitHub CLI is one auth source, not a separate check. Informational rows never affect exit code. | `-v` |
| `apm doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` or `copilot: {}` to also publish for Codex or Copilot CLI consumers). The executable-trust row names malformed configuration under either `executables` or the deprecated `allowExecutables` key and reports local allows overridden by org policy. GitHub CLI is one auth source, not a separate check. Informational rows never affect exit code. | `-v` |
| `apm marketplace package add <source>` | Add a plugin entry to `marketplace.plugins` (source accepts `owner/repo` or `./path`) | `--name`, `--version`, `--ref` (mutable refs auto-resolved to SHA), `-d`/`--description`, `-s`/`--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease`, `--no-verify` |
| `apm marketplace package set <name>` | Update fields on an existing plugin entry | `--version`, `--ref` (mutable refs auto-resolved to SHA), `--description`, `--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease` |
| `apm marketplace package remove <name>` | Remove a plugin entry from `marketplace.plugins` | `--yes` |
Expand Down
21 changes: 21 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,27 @@ Schema rules:
checks that `version:` is present). Omit entirely to skip the gate.
- Unknown keys raise a schema error -- do not invent fields.

### Additional marketplace outputs: Codex and Copilot CLI

Beyond the default `.claude-plugin/marketplace.json`, `marketplace.outputs`
(map form, e.g. `outputs: [claude, codex, copilot]` or with an explicit
`path:`) can also select:

- `codex` -> `.agents/plugins/marketplace.json` (default path). Every
package needs a `category`. Sources compile to the object shape
`{source, url/path, ref?, sha?}`.
- `copilot` -> `.github/plugin/marketplace.json` (default path, override
with env var `APM_MARKETPLACE_COPILOT_PATH` -- reserved for a future
release, validated but not yet consumed). No `category` requirement.
`plugins[].source` is always a relative-path **string** (e.g.
`"./plugins/demo"`) -- never the Claude/Codex object shape -- so
resolved `ref`/`sha` pins are not carried; only `apm install` (reading
`apm.yml`/`apm.lock.yaml` directly) reproduces the exact pin.

Each enabled output needs a matching `.gitignore` unignore line, e.g.
`!.github/plugin/marketplace.json` alongside
`!.claude-plugin/marketplace.json` and `!.agents/plugins/marketplace.json`.

### Cross-repo plugin sources on enterprise marketplaces

When a marketplace published on a `*.ghe.com` host references a plugin
Expand Down
131 changes: 131 additions & 0 deletions src/apm_cli/marketplace/output_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,75 @@ def compose(
return MapperResult(doc, (), tuple(name_diagnostics))


class CopilotMarketplaceMapper(MarketplaceOutputMapper):
"""Map packages into GitHub Copilot CLI marketplace format.

The Copilot CLI schema (see issue #2430) intentionally diverges from the
Claude mapper above in several ways:
- ``description`` and ``version`` nest under a top-level ``metadata``
object instead of living at the document root.
- ``plugins[].source`` must always be a relative path *string*
(e.g. ``"./plugins/demo"``) -- never the ``{source, url, ref, sha}``
pin-preserving object shape used by Claude/Codex.
- Fields the Copilot CLI does not understand (``author``, ``tags``,
``homepage``, ``repository``, ``license``, ``category``, git pin
metadata such as ``ref``/``sha``) are omitted entirely rather than
passed through.
"""

uses_remote_metadata = True

def compose(
self,
*,
config: MarketplaceConfig,
resolved: tuple[ResolvedPackage, ...],
remote_metadata: dict[str, dict[str, Any]] | None = None,
) -> MapperResult:
remote_metadata = remote_metadata or {}
entry_by_name: dict[str, PackageEntry] = {e.name: e for e in config.packages}

doc: dict[str, Any] = OrderedDict()
sanitized, name_diagnostics = _sanitized_name_with_diagnostic(config.name)
doc["name"] = sanitized
owner: dict[str, Any] = OrderedDict({"name": config.owner.name})
if config.owner.email:
owner["email"] = config.owner.email
doc["owner"] = owner

metadata: dict[str, Any] = OrderedDict()
if config.description:
metadata["description"] = config.description
if config.version:
metadata["version"] = config.version
if metadata:
doc["metadata"] = metadata
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

diagnostics = list(name_diagnostics)
plugins: list[dict[str, Any]] = []
for pkg in resolved:
entry = entry_by_name.get(pkg.name)
if entry is None:
continue
plugin: dict[str, Any] = OrderedDict({"name": pkg.name})
meta = remote_metadata.get(pkg.name, {})
description = entry.description or meta.get("description")
if description:
plugin["description"] = description
version = entry.version if entry.is_local else meta.get("version") or entry.version
if version:
plugin["version"] = version
plugin["source"] = _copilot_source(entry, pkg, diagnostics)
plugins.append(plugin)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

doc["plugins"] = plugins
return MapperResult(doc, (), tuple(diagnostics))


MARKETPLACE_OUTPUT_MAPPERS: dict[str, MarketplaceOutputMapper] = {
"claude": ClaudeMarketplaceMapper(),
"codex": CodexMarketplaceMapper(),
"copilot": CopilotMarketplaceMapper(),
}


Expand Down Expand Up @@ -383,6 +449,71 @@ def _codex_source(entry: PackageEntry, pkg: ResolvedPackage) -> dict[str, Any]:
return source_obj


def _copilot_source(
entry: PackageEntry,
pkg: ResolvedPackage,
diagnostics: list[BuildDiagnostic] | None = None,
) -> str:
"""Return a Copilot CLI ``source`` as a relative path string.

Unlike Claude/Codex, the Copilot CLI schema has no object-shaped source
(no ``url``/``repo``/``ref``/``sha`` pin metadata) -- it only accepts a
relative path. Local packages keep their configured path unchanged.
Remote packages prefer the resolved ``subdir`` (normalized to a leading
``./``); when a package has no subdir, fall back to a relative path
derived from the package name so every entry still resolves to a
sensible on-disk location.

Trade-off (by design, not a bug): resolved ``ref``/``sha`` pin metadata
is intentionally dropped for remote packages, because the Copilot CLI
schema has nowhere to carry it -- a consumer reinstalling from
``marketplace.json`` alone cannot reproduce the exact pinned commit the
producer resolved at pack time; only ``apm install`` (which reads
``apm.yml``/``apm.lock.yaml`` directly) retains that precision. Likewise,
a package with no ``subdir`` gets a *fabricated* ``./<pkg.name>`` path --
this is a best-effort placeholder, not a verified on-disk location; if
the installed layout differs, set an explicit ``subdir:`` on the package
entry. Both trade-offs surface as ``verbose``-level diagnostics below so
authors can audit them without changing the emitted (intentionally
minimal) Copilot schema.
"""
if entry.is_local:
return entry.source

if diagnostics is not None and (pkg.ref or pkg.sha):
diagnostics.append(
BuildDiagnostic(
level="verbose",
message=(
f"Copilot output for '{pkg.name}': resolved pin "
f"(ref={pkg.ref or '-'}, sha={pkg.sha or '-'}) is not "
f"carried in the Copilot schema's path-only 'source'. "
f"Use 'apm install' (reads apm.yml/apm.lock.yaml) to "
f"reproduce the exact pin."
),
)
)

if pkg.subdir:
relative = pkg.subdir.strip("/")
return relative if relative.startswith("./") else f"./{relative}"

if diagnostics is not None:
diagnostics.append(
BuildDiagnostic(
level="verbose",
message=(
f"Copilot output for '{pkg.name}': no 'subdir' resolved; "
f"fabricated './{pkg.name}' as a best-effort placeholder "
f"path. Set an explicit 'subdir:' on the package entry if "
f"the installed layout differs."
),
)
)

return f"./{pkg.name}"


def _apply_field_with_precedence(
plugin: dict[str, Any],
diagnostics: list[BuildDiagnostic],
Expand Down
9 changes: 9 additions & 0 deletions src/apm_cli/marketplace/output_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,20 @@ def _validate_profile(profile: MarketplaceOutputProfile) -> None:
required_package_fields=("category",),
)

COPILOT_MARKETPLACE_OUTPUT = MarketplaceOutputProfile(
name="copilot",
config_attr="copilot",
default_output=".github/plugin/marketplace.json",
mapper="copilot",
path_env_var="APM_MARKETPLACE_COPILOT_PATH",
)

MARKETPLACE_OUTPUTS: dict[str, MarketplaceOutputProfile] = {
profile.name: profile
for profile in (
DEFAULT_MARKETPLACE_OUTPUT,
CODEX_MARKETPLACE_OUTPUT,
COPILOT_MARKETPLACE_OUTPUT,
)
}
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

Expand Down
Loading
Loading