From 152c781d036abc0e9a06d2fdf93f4420b276e21b Mon Sep 17 00:00:00 2001 From: Henry Dennis Date: Mon, 11 May 2026 18:20:52 +0100 Subject: [PATCH] feat(marketplace): support multi-profile outputs --- .../docs/producer/publish-to-a-marketplace.md | 53 +- docs/src/content/docs/reference/cli/pack.md | 17 +- src/apm_cli/commands/marketplace/__init__.py | 16 +- src/apm_cli/commands/marketplace/init.py | 3 +- src/apm_cli/commands/pack.py | 41 +- src/apm_cli/core/build_orchestrator.py | 54 +- src/apm_cli/marketplace/builder.py | 502 ++++++++---------- src/apm_cli/marketplace/diagnostics.py | 13 + src/apm_cli/marketplace/init_template.py | 15 + src/apm_cli/marketplace/output_mappers.py | 342 ++++++++++++ src/apm_cli/marketplace/output_profiles.py | 63 +++ src/apm_cli/marketplace/yml_schema.py | 160 +++++- tests/integration/test_pack_unified.py | 32 ++ tests/unit/commands/test_marketplace_init.py | 16 +- tests/unit/core/test_build_orchestrator.py | 153 ++++++ .../test_apm_yml_marketplace_loader.py | 299 +++++++++++ tests/unit/marketplace/test_builder.py | 6 +- .../marketplace/test_local_path_compose.py | 140 ++++- 18 files changed, 1564 insertions(+), 361 deletions(-) create mode 100644 src/apm_cli/marketplace/diagnostics.py create mode 100644 src/apm_cli/marketplace/output_mappers.py create mode 100644 src/apm_cli/marketplace/output_profiles.py diff --git a/docs/src/content/docs/producer/publish-to-a-marketplace.md b/docs/src/content/docs/producer/publish-to-a-marketplace.md index d1b86c738..28a052e56 100644 --- a/docs/src/content/docs/producer/publish-to-a-marketplace.md +++ b/docs/src/content/docs/producer/publish-to-a-marketplace.md @@ -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. @@ -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 ``` @@ -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`. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index 35748cdf1..4fbfc4230 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -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 --- @@ -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 ` can verify integrity at install time. @@ -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. | @@ -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 @@ -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 @@ -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 ` 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 diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index 2603195da..aa3b50434 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -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 @@ -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 @@ -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 diff --git a/src/apm_cli/commands/marketplace/init.py b/src/apm_cli/commands/marketplace/init.py index bb5229aa9..83309188c 100644 --- a/src/apm_cli/commands/marketplace/init.py +++ b/src/apm_cli/commands/marketplace/init.py @@ -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: diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index bd380acbe..4ca4141e2 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -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. @@ -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: @@ -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", @@ -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): @@ -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) - 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( diff --git a/src/apm_cli/core/build_orchestrator.py b/src/apm_cli/core/build_orchestrator.py index 1bf48277c..accb31357 100644 --- a/src/apm_cli/core/build_orchestrator.py +++ b/src/apm_cli/core/build_orchestrator.py @@ -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, ) @@ -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] = [] @@ -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 @@ -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, ) diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index 2d411ac3f..f506f1679 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -22,20 +22,20 @@ import re import urllib.error import urllib.request -from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple # noqa: F401, UP035 +import yaml + if TYPE_CHECKING: from ..core.auth import HostInfo -import yaml - from ..utils.github_host import default_host from ..utils.path_security import ensure_path_within from ._io import atomic_write +from .diagnostics import BuildDiagnostic from .errors import ( BuildError, HeadNotAllowedError, @@ -43,6 +43,21 @@ OfflineMissError, # noqa: F401 RefNotFoundError, ) +from .output_mappers import ( + MARKETPLACE_OUTPUT_MAPPERS, + MapperResult, +) +from .output_mappers import ( + _is_display_version as _mapper_is_display_version, +) +from .output_mappers import ( + _subtract_plugin_root as _mapper_subtract_plugin_root, +) +from .output_profiles import ( + CODEX_MARKETPLACE_OUTPUT, + DEFAULT_MARKETPLACE_OUTPUT, + MarketplaceOutputProfile, +) from .ref_resolver import RefResolver, RemoteRef # noqa: F401 from .semver import SemVer, parse_semver, satisfies_range from .tag_pattern import build_tag_regex, render_tag # noqa: F401 @@ -64,14 +79,6 @@ # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class BuildDiagnostic: - """Structured diagnostic emitted during marketplace.json composition.""" - - level: str # "warning" | "verbose" - message: str - - @dataclass(frozen=True) class ResolvedPackage: """A package entry after ref resolution.""" @@ -100,9 +107,10 @@ def ok(self) -> bool: @dataclass(frozen=True) -class BuildReport: - """Summary of a build run.""" +class MarketplaceOutputReport: + """Summary for one generated marketplace output profile.""" + profile: str resolved: tuple[ResolvedPackage, ...] errors: tuple[tuple[str, str], ...] # (package name, error message) pairs warnings: tuple[str, ...] # non-fatal diagnostic messages @@ -115,6 +123,65 @@ class BuildReport: dry_run: bool = False +@dataclass(frozen=True) +class BuildReport: + """Summary of a marketplace build run across one or more output profiles.""" + + outputs: tuple[MarketplaceOutputReport, ...] + + @property + def primary_output(self) -> MarketplaceOutputReport: + """Return the first output report for legacy single-output callers.""" + if not self.outputs: + return MarketplaceOutputReport( + profile="", + resolved=(), + errors=(), + warnings=(), + ) + return self.outputs[0] + + @property + def resolved(self) -> tuple[ResolvedPackage, ...]: + return self.primary_output.resolved + + @property + def errors(self) -> tuple[tuple[str, str], ...]: + return self.primary_output.errors + + @property + def warnings(self) -> tuple[str, ...]: + return tuple(warn for output in self.outputs for warn in output.warnings) + + @property + def diagnostics(self) -> tuple[BuildDiagnostic, ...]: + return tuple(diag for output in self.outputs for diag in output.diagnostics) + + @property + def unchanged_count(self) -> int: + return self.primary_output.unchanged_count + + @property + def added_count(self) -> int: + return self.primary_output.added_count + + @property + def updated_count(self) -> int: + return self.primary_output.updated_count + + @property + def removed_count(self) -> int: + return self.primary_output.removed_count + + @property + def output_path(self) -> Path: + return self.primary_output.output_path + + @property + def dry_run(self) -> bool: + return any(output.dry_run for output in self.outputs) + + @dataclass class BuildOptions: """Configuration knobs for MarketplaceBuilder.""" @@ -125,6 +192,8 @@ class BuildOptions: allow_head: bool = False continue_on_error: bool = False offline: bool = False + marketplace_output: Path | None = None + # Backwards-compatible spelling for callers that predate ``apm pack``. output_override: Path | None = None dry_run: bool = False @@ -136,63 +205,15 @@ class BuildOptions: # 40-char hex SHA pattern _SHA40_RE = re.compile(r"^[0-9a-f]{40}$") -# Version range indicators -- if a version string starts with any of these -# or contains spaces, it's a resolution constraint, not a display override. -_VERSION_RANGE_CHARS = ("^", "~", ">", "<", "=") - def _is_display_version(version: str | None) -> bool: """Return True if *version* looks like a fixed display version, not a range.""" - if not version: - return False - v = version.strip() - if any(v.startswith(c) for c in _VERSION_RANGE_CHARS): - return False - return not (" " in v or "*" in v or "x" in v.lower().split(".")[-1:]) + return _mapper_is_display_version(version) def _subtract_plugin_root(source: str, plugin_root: str) -> str: - """Remove pluginRoot prefix from a local source path for emit. - - Uses PurePosixPath.relative_to() for robust normalization. - Returns the relative path prefixed with ``./``. - - Raises - ------ - ValueError - If *source* does not start with *plugin_root*. - BuildError - If subtraction yields an empty or invalid path (S2 guard). - """ - from pathlib import PurePosixPath - - # Normalize: strip leading "./" for comparison - norm_source = source.lstrip("./") if source.startswith("./") else source - norm_root = plugin_root.lstrip("./") if plugin_root.startswith("./") else plugin_root - # Strip trailing slashes - norm_root = norm_root.rstrip("/") - norm_source = norm_source.rstrip("/") - - src_path = PurePosixPath(norm_source) - root_path = PurePosixPath(norm_root) - - # relative_to raises ValueError if not a prefix - relative = src_path.relative_to(root_path) - result = str(relative) - - # X1: empty result means source == pluginRoot exactly - if not result or result == ".": - raise BuildError( - f"subtracting pluginRoot '{plugin_root}' from source '{source}' yields empty path" - ) - - # S2: post-subtraction guard -- no absolute paths, no traversal - if result.startswith("/"): - raise BuildError(f"pluginRoot subtraction produced absolute path: '{result}'") - if ".." in result.split("/"): - raise BuildError(f"pluginRoot subtraction produced path with traversal: '{result}'") - - return "./" + result + """Remove pluginRoot prefix from a local source path for emit.""" + return _mapper_subtract_plugin_root(source, plugin_root) class MarketplaceBuilder: @@ -300,14 +321,47 @@ def _ensure_auth(self) -> None: # -- output path -------------------------------------------------------- def _output_path(self) -> Path: + if self._options.marketplace_output is not None: + return self._options.marketplace_output if self._options.output_override is not None: return self._options.output_override yml = self._load_yml() - output_path = self._project_root / yml.output + output_path = self._project_root / yml.claude.output # Containment guard -- reject output paths that escape the project root. ensure_path_within(output_path, self._project_root) return output_path + def _mapper_for_profile(self, profile: MarketplaceOutputProfile): + mapper = MARKETPLACE_OUTPUT_MAPPERS.get(profile.mapper) + if mapper is None: + raise BuildError(f"Unknown marketplace output mapper: {profile.mapper}") + return mapper + + def remote_metadata_for_profile( + self, + profile: MarketplaceOutputProfile, + resolved: tuple[ResolvedPackage, ...], + ) -> dict[str, dict[str, Any]] | None: + """Return remote metadata needed to compose this output, if any.""" + mapper = self._mapper_for_profile(profile) + if not mapper.uses_remote_metadata: + return None + return self._prefetch_metadata(resolved) + + def _map_output( + self, + profile: MarketplaceOutputProfile, + resolved: tuple[ResolvedPackage, ...], + remote_metadata: dict[str, dict[str, Any]] | None = None, + ) -> MapperResult: + """Map resolved packages into one marketplace output format.""" + mapper = self._mapper_for_profile(profile) + return mapper.compose( + config=self._load_yml(), + resolved=resolved, + remote_metadata=remote_metadata, + ) + # -- single-entry resolution -------------------------------------------- def _resolve_entry(self, entry: PackageEntry) -> ResolvedPackage: @@ -710,213 +764,86 @@ def compose_marketplace_json(self, resolved: list[ResolvedPackage]) -> dict[str, dict An ``OrderedDict``-style dict ready to be serialised as JSON. """ + resolved_tuple = tuple(resolved) + mapper_result = self._map_output( + DEFAULT_MARKETPLACE_OUTPUT, + resolved_tuple, + remote_metadata=self._prefetch_metadata(resolved_tuple), + ) + self._compose_warnings = mapper_result.warnings + self._compose_diagnostics = mapper_result.diagnostics + return mapper_result.document + + def compose_codex_marketplace_json( + self, + resolved: list[ResolvedPackage], + ) -> tuple[dict[str, Any], tuple[str, ...]]: + """Produce a Codex ``.agents/plugins/marketplace.json`` document.""" + mapper_result = self._map_output(CODEX_MARKETPLACE_OUTPUT, tuple(resolved)) + return mapper_result.document, mapper_result.warnings + + def write_codex_marketplace_json( + self, + resolved: tuple[ResolvedPackage, ...], + ) -> tuple[Path, tuple[str, ...]]: + """Write the configured Codex marketplace output using resolved packages.""" yml = self._load_yml() + output_path = self._project_root / yml.codex.output + ensure_path_within(output_path, self._project_root) + output = self.write_output(CODEX_MARKETPLACE_OUTPUT, resolved, output_path) + return output.output_path, output.warnings - # Pre-fetch metadata (description + version) from remote apm.yml - remote_metadata = self._prefetch_metadata(resolved) - - # Build a name -> entry map so we can reach back for local-package - # description / homepage that came from the yml itself. - entry_by_name: dict[str, PackageEntry] = {e.name: e for e in yml.packages} - - doc: dict[str, Any] = OrderedDict() - doc["name"] = yml.name - # Top-level description / version are emitted only when explicitly - # set in the marketplace block (or in a legacy marketplace.yml). - # apm.yml-sourced configs that inherit these from the project skip - # them so the marketplace.json doesn't drift on unrelated bumps. - if yml.description_overridden and yml.description: - doc["description"] = yml.description - if yml.version_overridden and yml.version: - doc["version"] = yml.version - - # Owner -- omit empty optional sub-fields - owner_dict: dict[str, Any] = OrderedDict() - owner_dict["name"] = yml.owner.name - if yml.owner.email: - owner_dict["email"] = yml.owner.email - if yml.owner.url: - owner_dict["url"] = yml.owner.url - doc["owner"] = owner_dict - - # Metadata -- pass-through verbatim (only if present) - if yml.metadata: - doc["metadata"] = yml.metadata - - # Plugins (packages -> plugins) - plugins: list[dict[str, Any]] = [] - diagnostics: list[BuildDiagnostic] = [] - plugin_root = yml.metadata.get("pluginRoot", "") - strip_count = 0 - override_count = 0 - - for pkg in resolved: - plugin: dict[str, Any] = OrderedDict() - plugin["name"] = pkg.name - - entry = entry_by_name.get(pkg.name) - is_local = entry is not None and entry.is_local - - # -- description / version (with curator-wins override for remote) -- - if is_local: - if entry.description: - plugin["description"] = entry.description - if entry.version: - plugin["version"] = entry.version - else: - meta = remote_metadata.get(pkg.name, {}) - # Curator-wins: entry-level value overrides remote-fetched - if entry and entry.description: - plugin["description"] = entry.description - remote_desc = meta.get("description", "") - if remote_desc and remote_desc != entry.description: - override_count += 1 - diagnostics.append( - BuildDiagnostic( - level="verbose", - message=( - f"[i] Package '{pkg.name}': using curator " - f"description (remote: " - f"'{remote_desc[:40]}')" - ), - ) - ) - elif meta.get("description"): - plugin["description"] = meta["description"] - - if entry and _is_display_version(entry.version): - plugin["version"] = entry.version - remote_ver = meta.get("version", "") - if remote_ver and remote_ver != entry.version: - override_count += 1 - diagnostics.append( - BuildDiagnostic( - level="verbose", - message=( - f"[i] Package '{pkg.name}': using curator " - f"version '{entry.version}' " - f"(remote: '{remote_ver}')" - ), - ) - ) - elif meta.get("version"): - plugin["version"] = meta["version"] - - # -- author / license / repository (curator-only pass-through) -- - # ``author`` is normalized to an object by the loader, so we can - # serialize it as-is into the JSON. dict() drops the read-only - # Mapping wrapper while preserving insertion order (3.7+). - if entry and entry.author: - plugin["author"] = dict(entry.author) - if entry and entry.license: - plugin["license"] = entry.license - if entry and entry.repository: - plugin["repository"] = entry.repository - - # -- tags -- - if pkg.tags: - plugin["tags"] = list(pkg.tags) - - # -- homepage (local only) -- - if is_local and entry.homepage: - plugin["homepage"] = entry.homepage - - # -- source -- - if is_local: - source_value = entry.source - if plugin_root: - try: - source_value = _subtract_plugin_root(entry.source, plugin_root) - strip_count += 1 - diagnostics.append( - BuildDiagnostic( - level="verbose", - message=( - f"[i] Package '{pkg.name}': stripped " - f"pluginRoot -- '{entry.source}' -> " - f"'{source_value}'" - ), - ) - ) - except ValueError: - # W1: source outside pluginRoot -- emit as-is - source_value = entry.source - diagnostics.append( - BuildDiagnostic( - level="warning", - message=( - f"[!] Package '{pkg.name}': source " - f"'{entry.source}' is outside pluginRoot " - f"'{plugin_root}' -- emitted as-is" - ), - ) - ) - plugin["source"] = source_value - else: - # Remote source: emit per the official Claude Code marketplace - # schema (json.schemastore.org/claude-code-marketplace.json). - # Subdirs use the ``git-subdir`` form; everything else uses - # ``github`` shorthand. Field names: ``source``/``repo``/``sha`` - # (NOT ``type``/``repository``/``commit``). - source_obj: dict[str, Any] = OrderedDict() - if pkg.subdir: - source_obj["source"] = "git-subdir" - source_obj["url"] = pkg.source_repo - source_obj["path"] = pkg.subdir - else: - source_obj["source"] = "github" - source_obj["repo"] = pkg.source_repo - if pkg.ref: - source_obj["ref"] = pkg.ref - if pkg.sha: - source_obj["sha"] = pkg.sha - plugin["source"] = source_obj - - plugins.append(plugin) - - # Verbose summary line - summary_parts: list[str] = [] - if plugin_root and strip_count > 0: - summary_parts.append(f"stripped from {strip_count} local source(s)") - if override_count > 0: - summary_parts.append( - f"{override_count} remote entry(ies) used curator-supplied overrides" - ) - if summary_parts: - diagnostics.append( - BuildDiagnostic( - level="verbose", - message="pluginRoot: " + "; ".join(summary_parts), - ) - ) + def compose_output( + self, + profile: MarketplaceOutputProfile, + resolved: tuple[ResolvedPackage, ...], + remote_metadata: dict[str, dict[str, Any]] | None = None, + ) -> tuple[dict[str, Any], tuple[str, ...], tuple[BuildDiagnostic, ...]]: + """Compose the JSON document for a marketplace output profile.""" + mapper_result = self._map_output(profile, resolved, remote_metadata=remote_metadata) + return mapper_result.document, mapper_result.warnings, mapper_result.diagnostics + + def write_output( + self, + profile: MarketplaceOutputProfile, + resolved: tuple[ResolvedPackage, ...], + output_path: Path, + *, + include_diff: bool = False, + remote_metadata: dict[str, dict[str, Any]] | None = None, + errors: tuple[tuple[str, str], ...] = (), + ) -> BuildReport: + """Write one marketplace output profile using already resolved packages.""" + ensure_path_within(output_path, self._project_root) + new_json, warnings, diagnostics = self.compose_output( + profile, + resolved, + remote_metadata=remote_metadata, + ) - # Defence-in-depth: detect duplicate plugin names and record - # warnings so the command layer can alert the maintainer. - seen_names: dict[str, str] = {} - build_warnings: list[str] = [] - for p in plugins: - pname = p["name"] - src = p.get("source", {}) - if isinstance(src, str): - src_label = src - else: - # Prefer ``path`` (git-subdir form) for disambiguation, then - # fall back to ``repo`` (github form, post-1061) or - # ``repository`` (legacy emit shape, kept for back-compat). - src_label = src.get("path") or src.get("repo") or src.get("repository", "?") - if pname in seen_names: - build_warnings.append( - f"Duplicate package name '{pname}': " - f"'{seen_names[pname]}' and '{src_label}'. " - f"Consumers will see duplicate entries in browse." - ) - else: - seen_names[pname] = src_label - self._compose_warnings = tuple(build_warnings) - self._compose_diagnostics = tuple(diagnostics) + unchanged = added = updated = removed = 0 + if include_diff: + old_json = self._load_existing_json(output_path) + unchanged, added, updated, removed = self._compute_diff(old_json, new_json) + + if not self._options.dry_run: + output_path.parent.mkdir(parents=True, exist_ok=True) + self._atomic_write(output_path, self._serialize_json(new_json)) - doc["plugins"] = plugins - return doc + output_report = MarketplaceOutputReport( + profile=profile.name, + resolved=tuple(resolved), + errors=tuple(errors), + warnings=tuple(warnings), + diagnostics=tuple(diagnostics), + unchanged_count=unchanged, + added_count=added, + updated_count=updated, + removed_count=removed, + output_path=output_path, + dry_run=self._options.dry_run, + ) + return BuildReport(outputs=(output_report,)) # -- diff --------------------------------------------------------------- @@ -1009,39 +936,24 @@ def build(self) -> BuildReport: Summary including diff statistics. """ result = self.resolve() - resolved = list(result.entries) - errors = result.errors - - new_json = self.compose_marketplace_json(resolved) - build_warnings = getattr(self, "_compose_warnings", ()) - build_diagnostics = getattr(self, "_compose_diagnostics", ()) - output_path = self._output_path() - - # Load existing for diff - old_json = self._load_existing_json(output_path) - unchanged, added, updated, removed = self._compute_diff(old_json, new_json) - - # Write (unless dry-run) - if not self._options.dry_run: - output_path.parent.mkdir(parents=True, exist_ok=True) - content = self._serialize_json(new_json) - self._atomic_write(output_path, content) + report = self.write_output( + DEFAULT_MARKETPLACE_OUTPUT, + result.entries, + self._output_path(), + include_diff=True, + errors=result.errors, + remote_metadata=self.remote_metadata_for_profile( + DEFAULT_MARKETPLACE_OUTPUT, + result.entries, + ), + ) # Cleanup resolver if self._resolver is not None: self._resolver.close() return BuildReport( - resolved=tuple(resolved), - errors=tuple(errors), - warnings=tuple(build_warnings), - diagnostics=tuple(build_diagnostics), - unchanged_count=unchanged, - added_count=added, - updated_count=updated, - removed_count=removed, - output_path=output_path, - dry_run=self._options.dry_run, + outputs=report.outputs, ) diff --git a/src/apm_cli/marketplace/diagnostics.py b/src/apm_cli/marketplace/diagnostics.py new file mode 100644 index 000000000..4465401c5 --- /dev/null +++ b/src/apm_cli/marketplace/diagnostics.py @@ -0,0 +1,13 @@ +"""Shared marketplace build diagnostics.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BuildDiagnostic: + """Structured diagnostic emitted during marketplace output composition.""" + + level: str # "warning" | "verbose" + message: str diff --git a/src/apm_cli/marketplace/init_template.py b/src/apm_cli/marketplace/init_template.py index 02d3536dd..e7dac3d5f 100644 --- a/src/apm_cli/marketplace/init_template.py +++ b/src/apm_cli/marketplace/init_template.py @@ -89,6 +89,7 @@ def render_marketplace_yml_template( _MARKETPLACE_BLOCK_TEMPLATE = """\ # Marketplace authoring config (APM-only). # Run 'apm pack' to compile this block to .claude-plugin/marketplace.json. +# Optionally enable Codex output below to also write .agents/plugins/marketplace.json. # # Top-level 'name', 'description', and 'version' are inherited from # the project (above) by default. Override them inside this block when @@ -106,11 +107,25 @@ def render_marketplace_yml_template( build: tagPattern: "v{{version}}" + # Output targets. Claude output is the default for backwards compatibility. + # Add outputs: [claude, codex] to also write .agents/plugins/marketplace.json, + # or outputs: [codex] to build Codex only. + # outputs: [claude, codex] + # + # claude: + # output: .claude-plugin/marketplace.json + # + # Optional Codex output overrides: + # codex: + # output: .agents/plugins/marketplace.json + packages: - name: example-package description: Human-readable description of the package source: {owner}/example-package version: "^1.0.0" + # Required when outputs includes codex: + # category: Productivity # Optional overrides: # subdir: path/inside/repo # tagPattern: "example-package-v{{version}}" diff --git a/src/apm_cli/marketplace/output_mappers.py b/src/apm_cli/marketplace/output_mappers.py new file mode 100644 index 000000000..049cbb844 --- /dev/null +++ b/src/apm_cli/marketplace/output_mappers.py @@ -0,0 +1,342 @@ +"""Marketplace output mappers. + +Mappers translate resolved marketplace packages into each output format's JSON +shape. ``MarketplaceBuilder`` owns resolution, paths, writing, and diffing; +these classes own format-specific field mapping. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import OrderedDict +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from .diagnostics import BuildDiagnostic +from .errors import BuildError + +if TYPE_CHECKING: + from .builder import ResolvedPackage + from .yml_schema import MarketplaceConfig, PackageEntry + + +@dataclass(frozen=True) +class MapperResult: + """Composed output plus mapper diagnostics.""" + + document: dict[str, Any] + warnings: tuple[str, ...] = () + diagnostics: tuple[BuildDiagnostic, ...] = () + + +class MarketplaceOutputMapper(ABC): + """Base class for marketplace output format mappers.""" + + uses_remote_metadata = False + + @abstractmethod + def compose( + self, + *, + config: MarketplaceConfig, + resolved: tuple[ResolvedPackage, ...], + remote_metadata: dict[str, dict[str, Any]] | None = None, + ) -> MapperResult: + """Return the output JSON document for resolved packages.""" + + +class ClaudeMarketplaceMapper(MarketplaceOutputMapper): + """Map packages into Claude/Anthropic marketplace.json format.""" + + 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() + doc["name"] = config.name + if config.description_overridden and config.description: + doc["description"] = config.description + if config.version_overridden and config.version: + doc["version"] = config.version + owner_dict: dict[str, Any] = OrderedDict() + owner_dict["name"] = config.owner.name + if config.owner.email: + owner_dict["email"] = config.owner.email + if config.owner.url: + owner_dict["url"] = config.owner.url + doc["owner"] = owner_dict + if config.metadata: + doc["metadata"] = config.metadata + + plugin_root = config.metadata.get("pluginRoot", "") + strip_count = 0 + override_count = 0 + diagnostics: list[BuildDiagnostic] = [] + plugins: list[dict[str, Any]] = [] + + for pkg in resolved: + entry = entry_by_name.get(pkg.name) + is_local = bool(entry and entry.is_local) + plugin: dict[str, Any] = OrderedDict() + plugin["name"] = pkg.name + + if is_local: + if entry.description: + plugin["description"] = entry.description + if entry.version: + plugin["version"] = entry.version + else: + meta = remote_metadata.get(pkg.name, {}) + if entry and entry.description: + plugin["description"] = entry.description + remote_desc = meta.get("description", "") + if remote_desc and remote_desc != entry.description: + override_count += 1 + diagnostics.append( + BuildDiagnostic( + level="verbose", + message=( + f"[i] Package '{pkg.name}': using curator " + f"description (remote: " + f"'{remote_desc[:40]}')" + ), + ) + ) + elif meta.get("description"): + plugin["description"] = meta["description"] + + if entry and _is_display_version(entry.version): + plugin["version"] = entry.version + remote_ver = meta.get("version", "") + if remote_ver and remote_ver != entry.version: + override_count += 1 + diagnostics.append( + BuildDiagnostic( + level="verbose", + message=( + f"[i] Package '{pkg.name}': using curator " + f"version '{entry.version}' " + f"(remote: '{remote_ver}')" + ), + ) + ) + elif meta.get("version"): + plugin["version"] = meta["version"] + + if entry and entry.author: + plugin["author"] = dict(entry.author) + if entry and entry.license: + plugin["license"] = entry.license + if entry and entry.repository: + plugin["repository"] = entry.repository + if pkg.tags: + plugin["tags"] = list(pkg.tags) + if is_local and entry.homepage: + plugin["homepage"] = entry.homepage + + if is_local: + source_value = entry.source + if plugin_root: + try: + source_value = _subtract_plugin_root(entry.source, plugin_root) + strip_count += 1 + diagnostics.append( + BuildDiagnostic( + level="verbose", + message=( + f"[i] Package '{pkg.name}': stripped " + f"pluginRoot -- '{entry.source}' -> " + f"'{source_value}'" + ), + ) + ) + except ValueError: + source_value = entry.source + diagnostics.append( + BuildDiagnostic( + level="warning", + message=( + f"[!] Package '{pkg.name}': source " + f"'{entry.source}' is outside pluginRoot " + f"'{plugin_root}' -- emitted as-is" + ), + ) + ) + plugin["source"] = source_value + else: + source_obj: dict[str, Any] = OrderedDict() + if pkg.subdir: + source_obj["source"] = "git-subdir" + source_obj["url"] = pkg.source_repo + source_obj["path"] = pkg.subdir + else: + source_obj["source"] = "github" + source_obj["repo"] = pkg.source_repo + if pkg.ref: + source_obj["ref"] = pkg.ref + if pkg.sha: + source_obj["sha"] = pkg.sha + plugin["source"] = source_obj + + plugins.append(plugin) + + summary_parts: list[str] = [] + if plugin_root and strip_count > 0: + summary_parts.append(f"stripped from {strip_count} local source(s)") + if override_count > 0: + summary_parts.append( + f"{override_count} remote entry(ies) used curator-supplied overrides" + ) + if summary_parts: + diagnostics.append( + BuildDiagnostic( + level="verbose", + message="pluginRoot: " + "; ".join(summary_parts), + ) + ) + + warnings = _duplicate_name_warnings(plugins) + doc["plugins"] = plugins + return MapperResult(doc, tuple(warnings), tuple(diagnostics)) + + +class CodexMarketplaceMapper(MarketplaceOutputMapper): + """Map packages into Codex repo marketplace format.""" + + def compose( + self, + *, + config: MarketplaceConfig, + resolved: tuple[ResolvedPackage, ...], + remote_metadata: dict[str, dict[str, Any]] | None = None, + ) -> MapperResult: + entry_by_name: dict[str, PackageEntry] = {e.name: e for e in config.packages} + + doc: dict[str, Any] = OrderedDict() + doc["name"] = config.name + doc["interface"] = OrderedDict({"displayName": config.name}) + + 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() + plugin["name"] = pkg.name + plugin["source"] = _codex_source(entry, pkg) + plugin["policy"] = OrderedDict( + { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + } + ) + if not entry.category: + raise BuildError( + f"package '{entry.name}' is missing category required for Codex output" + ) + plugin["category"] = entry.category + plugins.append(plugin) + + doc["plugins"] = plugins + return MapperResult(doc) + + +MARKETPLACE_OUTPUT_MAPPERS: dict[str, MarketplaceOutputMapper] = { + "claude": ClaudeMarketplaceMapper(), + "codex": CodexMarketplaceMapper(), +} + + +def _codex_source(entry: PackageEntry, pkg: ResolvedPackage) -> dict[str, Any]: + if entry.is_local: + return OrderedDict( + { + "source": "local", + "path": entry.source, + } + ) + if pkg.subdir: + source_obj: dict[str, Any] = OrderedDict() + source_obj["source"] = "git-subdir" + source_obj["url"] = pkg.source_repo + source_obj["path"] = pkg.subdir + if pkg.ref: + source_obj["ref"] = pkg.ref + if pkg.sha: + source_obj["sha"] = pkg.sha + return source_obj + + source_obj = OrderedDict() + source_obj["source"] = "url" + source_obj["url"] = pkg.source_repo + if pkg.ref: + source_obj["ref"] = pkg.ref + if pkg.sha: + source_obj["sha"] = pkg.sha + return source_obj + + +def _duplicate_name_warnings(plugins: list[dict[str, Any]]) -> list[str]: + seen_names: dict[str, str] = {} + warnings: list[str] = [] + for plugin in plugins: + name = plugin["name"] + source = plugin.get("source", {}) + if isinstance(source, str): + source_label = source + else: + source_label = source.get("path") or source.get("repo") or source.get("repository", "?") + if name in seen_names: + warnings.append( + f"Duplicate package name '{name}': " + f"'{seen_names[name]}' and '{source_label}'. " + f"Consumers will see duplicate entries in browse." + ) + else: + seen_names[name] = source_label + return warnings + + +def _is_display_version(value: str | None) -> bool: + if not value: + return False + stripped = value.strip() + if any(stripped.startswith(char) for char in ("^", "~", ">", "<", "=")): + return False + return not (" " in stripped or "*" in stripped or "x" in stripped.lower().split(".")[-1:]) + + +def _subtract_plugin_root(source: str, plugin_root: str) -> str: + from pathlib import PurePosixPath + + norm_source = source.lstrip("./") if source.startswith("./") else source + norm_root = plugin_root.lstrip("./") if plugin_root.startswith("./") else plugin_root + norm_root = norm_root.rstrip("/") + norm_source = norm_source.rstrip("/") + + src_path = PurePosixPath(norm_source) + root_path = PurePosixPath(norm_root) + + relative = src_path.relative_to(root_path) + result = str(relative) + + if not result or result == ".": + raise BuildError( + f"subtracting pluginRoot '{plugin_root}' from source '{source}' yields empty path" + ) + + if result.startswith("/"): + raise BuildError(f"pluginRoot subtraction produced absolute path: '{result}'") + if ".." in result.split("/"): + raise BuildError(f"pluginRoot subtraction produced path with traversal: '{result}'") + + return "./" + result diff --git a/src/apm_cli/marketplace/output_profiles.py b/src/apm_cli/marketplace/output_profiles.py new file mode 100644 index 000000000..087f5b92b --- /dev/null +++ b/src/apm_cli/marketplace/output_profiles.py @@ -0,0 +1,63 @@ +"""Marketplace output profiles. + +This mirrors ``integration.targets`` for marketplace artifact generation: +``outputs`` selects named profiles, and each profile owns the artifact's +default path, config namespace, mapper, and required package metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class MarketplaceOutputProfile: + """Capabilities and layout of a marketplace output artifact.""" + + name: str + """Short unique identifier (``"claude"``, ``"codex"``).""" + + config_attr: str + """Attribute on ``MarketplaceConfig`` containing output-specific config.""" + + default_output: str + """Default output path relative to the project root.""" + + mapper: str + """Mapper identifier used by ``MarketplaceBuilder`` to build the JSON.""" + + required_package_fields: tuple[str, ...] = () + """PackageEntry fields required when this output is selected.""" + + supports_cli_output_override: bool = False + """Whether ``apm pack --marketplace-output`` can override this output path.""" + + +DEFAULT_MARKETPLACE_OUTPUT = MarketplaceOutputProfile( + name="claude", + config_attr="claude", + default_output=".claude-plugin/marketplace.json", + mapper="claude", + supports_cli_output_override=True, +) + +CODEX_MARKETPLACE_OUTPUT = MarketplaceOutputProfile( + name="codex", + config_attr="codex", + default_output=".agents/plugins/marketplace.json", + mapper="codex", + required_package_fields=("category",), +) + +MARKETPLACE_OUTPUTS: dict[str, MarketplaceOutputProfile] = { + profile.name: profile + for profile in ( + DEFAULT_MARKETPLACE_OUTPUT, + CODEX_MARKETPLACE_OUTPUT, + ) +} + + +def known_output_names() -> frozenset[str]: + """Return the supported marketplace output names.""" + return frozenset(MARKETPLACE_OUTPUTS) diff --git a/src/apm_cli/marketplace/yml_schema.py b/src/apm_cli/marketplace/yml_schema.py index dd568386b..bb1ae5d63 100644 --- a/src/apm_cli/marketplace/yml_schema.py +++ b/src/apm_cli/marketplace/yml_schema.py @@ -40,11 +40,14 @@ from ..utils.path_security import PathTraversalError, validate_path_segments from .errors import MarketplaceYmlError +from .output_profiles import MARKETPLACE_OUTPUTS, known_output_names __all__ = [ "LOCAL_SOURCE_RE", "SOURCE_RE", "MarketplaceBuild", + "MarketplaceClaudeConfig", + "MarketplaceCodexConfig", "MarketplaceConfig", "MarketplaceOwner", "MarketplaceYml", # backwards-compat alias @@ -100,6 +103,7 @@ "license", "repository", "keywords", + "category", } ) @@ -163,11 +167,27 @@ def _parse_author(raw: Any, index: int) -> dict[str, str] | None: "version", # optional override of top-level apm.yml version "owner", "output", + "outputs", + "claude", "metadata", "build", + "codex", "packages", } ) + +_CLAUDE_KEYS = frozenset( + { + "output", + } +) + +_CODEX_KEYS = frozenset( + { + "output", + } +) + # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- @@ -189,6 +209,20 @@ class MarketplaceBuild: tag_pattern: str = "v{version}" +@dataclass(frozen=True) +class MarketplaceClaudeConfig: + """Claude-specific marketplace output configuration.""" + + output: str = ".claude-plugin/marketplace.json" + + +@dataclass(frozen=True) +class MarketplaceCodexConfig: + """Codex-specific marketplace output configuration.""" + + output: str = MARKETPLACE_OUTPUTS["codex"].default_output + + @dataclass(frozen=True) class PackageEntry: """A single entry in the ``packages`` list. @@ -222,6 +256,9 @@ class PackageEntry: author: Mapping[str, str] | None = None license: str | None = None repository: str | None = None + # Marketplace category metadata. Emitted only by output formats that + # consume categories, currently Codex repo marketplace output. + category: str | None = None # Derived (set by loader, not by user) is_local: bool = False @@ -249,6 +286,9 @@ class MarketplaceConfig: version: str owner: MarketplaceOwner output: str = ".claude-plugin/marketplace.json" + outputs: tuple[str, ...] = ("claude",) + claude: MarketplaceClaudeConfig = field(default_factory=MarketplaceClaudeConfig) + codex: MarketplaceCodexConfig = field(default_factory=MarketplaceCodexConfig) metadata: dict[str, Any] = field(default_factory=dict) build: MarketplaceBuild = field(default_factory=MarketplaceBuild) packages: tuple[PackageEntry, ...] = () @@ -373,6 +413,84 @@ def _parse_build(raw: Any) -> MarketplaceBuild: return MarketplaceBuild(tag_pattern=tag_pattern) +def _parse_claude(raw: Any, *, default_output: str) -> MarketplaceClaudeConfig: + """Parse and validate the optional ``marketplace.claude`` block.""" + if raw is None: + return MarketplaceClaudeConfig(output=default_output) + if not isinstance(raw, dict): + raise MarketplaceYmlError("'claude' must be a mapping") + _check_unknown_keys(raw, _CLAUDE_KEYS, context="claude") + + output = raw.get("output", default_output) + if not isinstance(output, str) or not output.strip(): + raise MarketplaceYmlError("'claude.output' must be a non-empty string") + output = output.strip() + try: + validate_path_segments(output, context="claude.output") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + return MarketplaceClaudeConfig(output=output) + + +def _parse_codex(raw: Any) -> MarketplaceCodexConfig: + """Parse and validate the optional ``marketplace.codex`` block.""" + if raw is None: + return MarketplaceCodexConfig() + if not isinstance(raw, dict): + raise MarketplaceYmlError("'codex' must be a mapping") + _check_unknown_keys(raw, _CODEX_KEYS, context="codex") + + output = raw.get("output", MARKETPLACE_OUTPUTS["codex"].default_output) + if not isinstance(output, str) or not output.strip(): + raise MarketplaceYmlError("'codex.output' must be a non-empty string") + output = output.strip() + try: + validate_path_segments(output, context="codex.output") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + return MarketplaceCodexConfig(output=output) + + +def _parse_outputs(raw: Any) -> tuple[str, ...]: + """Parse the marketplace output selector list. + + ``outputs`` mirrors the repo's top-level target-selection pattern: + omit it for the backwards-compatible Claude output, or provide one + or more named marketplace artifacts to write. + """ + if raw is None: + return ("claude",) + if isinstance(raw, str): + raw_items = [raw] + elif isinstance(raw, list): + raw_items = raw + else: + raise MarketplaceYmlError("'outputs' must be a string or list of strings") + + outputs: list[str] = [] + seen: set[str] = set() + for index, item in enumerate(raw_items): + if not isinstance(item, str) or not item.strip(): + raise MarketplaceYmlError(f"'outputs[{index}]' must be a non-empty string") + output = item.strip() + known_outputs = known_output_names() + if output not in known_outputs: + raise MarketplaceYmlError( + f"Unknown marketplace output '{output}'. " + f"Permitted outputs: {', '.join(sorted(known_outputs))}" + ) + if output in seen: + raise MarketplaceYmlError(f"Duplicate marketplace output '{output}'") + seen.add(output) + outputs.append(output) + + if not outputs: + raise MarketplaceYmlError("'outputs' must contain at least one marketplace output") + return tuple(outputs) + + def _parse_package_entry(raw: Any, index: int) -> PackageEntry: """Parse and validate a single ``packages`` entry.""" if not isinstance(raw, dict): @@ -512,6 +630,15 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: raise MarketplaceYmlError(f"'packages[{index}].repository' must be a non-empty string") repository = repository.strip() + # Optional marketplace category. Claude output strips this; Codex output + # requires and emits it. + category: str | None = None + raw_category = raw.get("category") + if raw_category is not None: + if not isinstance(raw_category, str) or not raw_category.strip(): + raise MarketplaceYmlError(f"'packages[{index}].category' must be a non-empty string") + category = raw_category.strip() + return PackageEntry( name=name, source=source, @@ -526,6 +653,7 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: author=author, license=license_val, repository=repository, + category=category, is_local=is_local, ) @@ -731,10 +859,14 @@ def _build_config( raise MarketplaceYmlError("'owner' is required") owner = _parse_owner(raw_owner) - # -- output (default differs between legacy and new layouts) -- - output = marketplace_dict.get("output") - if output is None: - output = default_output + # -- output selection -- + outputs = _parse_outputs(marketplace_dict.get("outputs")) + + # -- Claude output (default differs between legacy and new layouts) -- + # ``output`` remains as a backwards-compatible shorthand for + # ``claude.output``. The explicit block wins when both are present. + legacy_output = marketplace_dict.get("output") + output = default_output if legacy_output is None else legacy_output if not isinstance(output, str) or not output.strip(): raise MarketplaceYmlError("'output' must be a non-empty string") output = output.strip() @@ -745,6 +877,9 @@ def _build_config( except PathTraversalError as exc: raise MarketplaceYmlError(str(exc)) from exc + claude = _parse_claude(marketplace_dict.get("claude"), default_output=output) + output = claude.output + # -- metadata (Anthropic pass-through, preserve verbatim) -- metadata: dict[str, Any] = {} raw_metadata = marketplace_dict.get("metadata") @@ -768,6 +903,9 @@ def _build_config( # -- build -- build = _parse_build(marketplace_dict.get("build")) + # -- codex output -- + codex = _parse_codex(marketplace_dict.get("codex")) + # -- packages -- raw_packages = marketplace_dict.get("packages") if raw_packages is None: @@ -788,12 +926,26 @@ def _build_config( seen_names[lower_name] = idx entries.append(entry) + for output_name in outputs: + profile = MARKETPLACE_OUTPUTS[output_name] + for field_name in profile.required_package_fields: + missing = [entry.name for entry in entries if not getattr(entry, field_name)] + if missing: + names = ", ".join(missing) + raise MarketplaceYmlError( + f"packages must define '{field_name}' when marketplace.outputs includes " + f"'{output_name}' (missing: {names})" + ) + return MarketplaceConfig( name=name, description=description, version=version, owner=owner, output=output, + outputs=outputs, + claude=claude, + codex=codex, metadata=metadata, build=build, packages=tuple(entries), diff --git a/tests/integration/test_pack_unified.py b/tests/integration/test_pack_unified.py index 64c48a6b2..9af892250 100644 --- a/tests/integration/test_pack_unified.py +++ b/tests/integration/test_pack_unified.py @@ -104,6 +104,38 @@ def test_pack_marketplace_only(self, runner, tmp_path, monkeypatch): # No bundle directory should appear assert not (tmp_path / "build").exists() + def test_pack_marketplace_writes_codex_when_selected(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_marketplace_block_yml(tmp_path) + apm = tmp_path / "apm.yml" + apm.write_text( + apm.read_text(encoding="utf-8") + .replace( + " owner:\n", + " outputs: [claude, codex]\n owner:\n", + 1, + ) + .replace( + " homepage: https://example.com\n", + " homepage: https://example.com\n category: Productivity\n", + 1, + ), + encoding="utf-8", + ) + + result = runner.invoke(pack_cmd, []) + + assert result.exit_code == 0, result.output + claude_out = tmp_path / ".claude-plugin" / "marketplace.json" + codex_out = tmp_path / ".agents" / "plugins" / "marketplace.json" + assert claude_out.exists() + assert codex_out.exists() + data = json.loads(codex_out.read_text(encoding="utf-8")) + assert data["plugins"][0]["source"] == { + "source": "local", + "path": "./.github/plugins/azure", + } + def test_pack_both(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # Add both blocks diff --git a/tests/unit/commands/test_marketplace_init.py b/tests/unit/commands/test_marketplace_init.py index 3db0f67c1..0a8a1191d 100644 --- a/tests/unit/commands/test_marketplace_init.py +++ b/tests/unit/commands/test_marketplace_init.py @@ -59,6 +59,14 @@ def test_injects_marketplace_block_into_existing_apm_yml( assert "owner" in block assert "packages" in block + def test_template_mentions_optional_codex_output(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0, result.output + text = (tmp_path / "apm.yml").read_text(encoding="utf-8") + assert "codex:" in text + assert ".agents/plugins/marketplace.json" in text + def test_success_message(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init"]) @@ -71,6 +79,7 @@ def test_next_steps_shown(self, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 0 assert "apm pack" in result.output + assert ".agents/plugins/marketplace.json" in result.output # --------------------------------------------------------------------------- @@ -111,7 +120,12 @@ def test_force_overwrites_existing_block(self, runner, tmp_path, monkeypatch): class TestInitGitignoreCheck: @pytest.mark.parametrize( "pattern", - ["marketplace.json\n", "**/marketplace.json\n", "/marketplace.json\n"], + [ + "marketplace.json\n", + "**/marketplace.json\n", + "/marketplace.json\n", + ".agents/plugins/marketplace.json\n", + ], ) def test_warns_when_gitignore_ignores_marketplace_json( self, diff --git a/tests/unit/core/test_build_orchestrator.py b/tests/unit/core/test_build_orchestrator.py index f508598ad..eb2b47612 100644 --- a/tests/unit/core/test_build_orchestrator.py +++ b/tests/unit/core/test_build_orchestrator.py @@ -13,10 +13,12 @@ BuildOptions, BuildOrchestrator, BuildResult, # noqa: F401 + MarketplaceProducer, OutputKind, ProducerResult, detect_outputs, ) +from apm_cli.marketplace.builder import BuildReport, MarketplaceOutputReport def _write(path: Path, text: str) -> None: @@ -186,3 +188,154 @@ def test_default_producers_are_bundle_and_marketplace(self): kinds = [p.kind for p in orch._producers] assert OutputKind.BUNDLE in kinds assert OutputKind.MARKETPLACE in kinds + + +class TestMarketplaceProducer: + def test_writes_claude_and_codex_outputs_when_requested(self, tmp_path: Path): + apm = tmp_path / "apm.yml" + _write( + apm, + "name: x\n" + "version: 0.1.0\n" + "description: y\n" + "marketplace:\n" + " owner:\n" + " name: o\n" + " outputs: [claude, codex]\n" + " packages:\n" + " - name: local-tool\n" + " source: ./plugins/local-tool\n" + " category: Productivity\n", + ) + opts = BuildOptions( + project_root=tmp_path, + apm_yml_path=apm, + marketplace_offline=True, + ) + + result = MarketplaceProducer().produce(opts, logger=None) + + claude_output = tmp_path / ".claude-plugin" / "marketplace.json" + codex_output = tmp_path / ".agents" / "plugins" / "marketplace.json" + assert claude_output in result.outputs + assert codex_output in result.outputs + assert claude_output.exists() + assert codex_output.exists() + + def test_writes_only_codex_when_requested(self, tmp_path: Path): + apm = tmp_path / "apm.yml" + _write( + apm, + "name: x\n" + "version: 0.1.0\n" + "description: y\n" + "marketplace:\n" + " owner:\n" + " name: o\n" + " outputs: [codex]\n" + " packages:\n" + " - name: local-tool\n" + " source: ./plugins/local-tool\n" + " category: Productivity\n", + ) + opts = BuildOptions( + project_root=tmp_path, + apm_yml_path=apm, + marketplace_offline=True, + ) + + result = MarketplaceProducer().produce(opts, logger=None) + + claude_output = tmp_path / ".claude-plugin" / "marketplace.json" + codex_output = tmp_path / ".agents" / "plugins" / "marketplace.json" + assert result.payload is not None + assert [output.profile for output in result.payload.outputs] == ["codex"] + assert result.outputs == [codex_output] + assert not claude_output.exists() + assert codex_output.exists() + + def test_marketplace_output_override_applies_only_to_claude_profile(self, tmp_path: Path): + apm = tmp_path / "apm.yml" + _write( + apm, + "name: x\n" + "version: 0.1.0\n" + "description: y\n" + "marketplace:\n" + " owner:\n" + " name: o\n" + " outputs: [claude, codex]\n" + " claude:\n" + " output: build/claude-config.json\n" + " codex:\n" + " output: build/codex-config.json\n" + " packages:\n" + " - name: local-tool\n" + " source: ./plugins/local-tool\n" + " category: Productivity\n", + ) + override = tmp_path / "override" / "claude.json" + opts = BuildOptions( + project_root=tmp_path, + apm_yml_path=apm, + marketplace_offline=True, + marketplace_output=override, + ) + + result = MarketplaceProducer().produce(opts, logger=None) + + codex_output = tmp_path / "build" / "codex-config.json" + assert result.payload is not None + assert [output.profile for output in result.payload.outputs] == ["claude", "codex"] + assert override in result.outputs + assert codex_output in result.outputs + assert override.exists() + assert codex_output.exists() + assert not (tmp_path / "build" / "claude-config.json").exists() + + def test_build_warnings_are_exposed_on_producer_result( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + apm = tmp_path / "apm.yml" + _write( + apm, + "name: x\n" + "version: 0.1.0\n" + "description: y\n" + "marketplace:\n" + " owner:\n" + " name: o\n" + " packages: []\n", + ) + output_path = tmp_path / ".claude-plugin" / "marketplace.json" + + def fake_write_output(self, *args, **kwargs): + return BuildReport( + outputs=( + MarketplaceOutputReport( + profile="claude", + resolved=(), + errors=(), + warnings=("duplicate package warning",), + output_path=output_path, + ), + ) + ) + + monkeypatch.setattr( + "apm_cli.marketplace.builder.MarketplaceBuilder.write_output", + fake_write_output, + ) + + result = MarketplaceProducer().produce( + BuildOptions( + project_root=tmp_path, + apm_yml_path=apm, + marketplace_offline=True, + ), + logger=None, + ) + + assert result.payload is not None + assert result.payload.warnings == ("duplicate package warning",) + assert result.warnings == ["duplicate package warning"] diff --git a/tests/unit/marketplace/test_apm_yml_marketplace_loader.py b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py index 288a62927..b5bcea916 100644 --- a/tests/unit/marketplace/test_apm_yml_marketplace_loader.py +++ b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py @@ -13,6 +13,7 @@ import pytest from apm_cli.marketplace.errors import MarketplaceYmlError +from apm_cli.marketplace.output_profiles import MARKETPLACE_OUTPUTS, known_output_names from apm_cli.marketplace.yml_schema import load_marketplace_from_apm_yml @@ -20,6 +21,15 @@ def _write(p: Path, content: str) -> None: p.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") +def test_marketplace_output_profiles_define_supported_outputs() -> None: + assert known_output_names() == {"claude", "codex"} + assert MARKETPLACE_OUTPUTS["claude"].mapper == "claude" + assert MARKETPLACE_OUTPUTS["claude"].supports_cli_output_override is True + assert MARKETPLACE_OUTPUTS["codex"].mapper == "codex" + assert MARKETPLACE_OUTPUTS["codex"].supports_cli_output_override is False + assert MARKETPLACE_OUTPUTS["codex"].required_package_fields == ("category",) + + _MIN_BLOCK_INHERIT = """\ name: my-project description: Project description. @@ -80,6 +90,130 @@ def test_default_output_is_claude_plugin(self, tmp_path: Path) -> None: _write(apm, _MIN_BLOCK_INHERIT) config = load_marketplace_from_apm_yml(apm) assert config.output == ".claude-plugin/marketplace.json" + assert config.outputs == ("claude",) + assert config.claude.output == ".claude-plugin/marketplace.json" + + def test_outputs_list_parsed(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + outputs: [claude, codex] + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("claude", "codex") + + def test_outputs_scalar_parsed(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + outputs: codex + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("codex",) + + def test_claude_block_parsed(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + outputs: [codex] + claude: + output: build/claude-marketplace.json + codex: + output: build/codex-marketplace.json + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("codex",) + assert config.claude.output == "build/claude-marketplace.json" + assert config.output == "build/claude-marketplace.json" + + def test_top_level_output_remains_claude_shorthand(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + output: build/legacy-output.json + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.claude.output == "build/legacy-output.json" + assert config.output == "build/legacy-output.json" + + def test_claude_block_wins_over_top_level_output(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + output: build/legacy-output.json + claude: + output: build/explicit-claude.json + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.claude.output == "build/explicit-claude.json" + assert config.output == "build/explicit-claude.json" + + def test_codex_defaults_disabled(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, _MIN_BLOCK_INHERIT) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("claude",) + assert config.codex.output == ".agents/plugins/marketplace.json" + + def test_codex_block_parsed(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: my-project + version: 1.2.3 + marketplace: + owner: + name: ACME + outputs: [claude, codex] + codex: + output: .agents/plugins/marketplace.json + packages: [] + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("claude", "codex") + assert config.codex.output == ".agents/plugins/marketplace.json" class TestValidation: @@ -120,6 +254,171 @@ def test_missing_owner_rejected(self, tmp_path: Path) -> None: with pytest.raises(MarketplaceYmlError, match="owner"): load_marketplace_from_apm_yml(apm) + def test_codex_unknown_key_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + codex: + nope: true + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match="nope"): + load_marketplace_from_apm_yml(apm) + + def test_codex_enabled_key_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + codex: + enabled: true + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match=r"enabled"): + load_marketplace_from_apm_yml(apm) + + def test_claude_enabled_key_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + claude: + enabled: true + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match=r"enabled"): + load_marketplace_from_apm_yml(apm) + + def test_outputs_must_not_be_empty(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [] + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match="at least one marketplace output"): + load_marketplace_from_apm_yml(apm) + + def test_unknown_output_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [claude, cursor] + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match="Unknown marketplace output 'cursor'"): + load_marketplace_from_apm_yml(apm) + + def test_duplicate_output_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [claude, claude] + packages: [] + """, + ) + with pytest.raises(MarketplaceYmlError, match="Duplicate marketplace output 'claude'"): + load_marketplace_from_apm_yml(apm) + + def test_codex_block_not_required_when_codex_output_selected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [codex] + packages: + - name: local-tool + source: ./plugins/local-tool + category: Productivity + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.outputs == ("codex",) + assert config.codex.output == ".agents/plugins/marketplace.json" + + def test_package_category_required_when_codex_output_selected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [codex] + packages: + - name: local-tool + source: ./plugins/local-tool + """, + ) + with pytest.raises(MarketplaceYmlError, match=r"category"): + load_marketplace_from_apm_yml(apm) + + def test_package_category_parsed(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write( + apm, + """\ + name: foo + version: 1.0.0 + marketplace: + owner: + name: A + outputs: [codex] + packages: + - name: local-tool + source: ./plugins/local-tool + category: Developer Tools + """, + ) + config = load_marketplace_from_apm_yml(apm) + assert config.packages[0].category == "Developer Tools" + class TestLocalPackages: def test_local_source_skips_version_requirement(self, tmp_path: Path) -> None: diff --git a/tests/unit/marketplace/test_builder.py b/tests/unit/marketplace/test_builder.py index cf23f2573..5c773da73 100644 --- a/tests/unit/marketplace/test_builder.py +++ b/tests/unit/marketplace/test_builder.py @@ -1047,9 +1047,9 @@ def test_empty_packages(self, tmp_path: Path) -> None: class TestOutputOverride: - """Tests for --output flag.""" + """Tests for --marketplace-output flag plumbing.""" - def test_custom_output_path(self, tmp_path: Path) -> None: + def test_custom_marketplace_output_path(self, tmp_path: Path) -> None: yml = """\ name: test-mkt description: Test @@ -1063,7 +1063,7 @@ def test_custom_output_path(self, tmp_path: Path) -> None: """ refs = {"acme/pkg1": _make_refs("v1.0.0")} custom_out = tmp_path / "custom" / "output.json" - opts = BuildOptions(output_override=custom_out) + opts = BuildOptions(marketplace_output=custom_out) report = _build_with_mock(tmp_path, yml, refs, options=opts) assert report.output_path == custom_out assert custom_out.exists() diff --git a/tests/unit/marketplace/test_local_path_compose.py b/tests/unit/marketplace/test_local_path_compose.py index ad88e85ef..44bb7dac7 100644 --- a/tests/unit/marketplace/test_local_path_compose.py +++ b/tests/unit/marketplace/test_local_path_compose.py @@ -11,8 +11,9 @@ import pytest -from apm_cli.marketplace.builder import BuildOptions, MarketplaceBuilder +from apm_cli.marketplace.builder import BuildOptions, MarketplaceBuilder, ResolvedPackage from apm_cli.marketplace.migration import load_marketplace_config +from apm_cli.marketplace.output_profiles import MARKETPLACE_OUTPUTS _APM_WITH_LOCAL_BLOCK = """\ name: my-project @@ -27,6 +28,7 @@ description: A locally vendored tool. homepage: https://example.com/local-tool version: 0.1.0 + category: Productivity tags: [local, demo] - name: remote-tool source: acme/remote-tool @@ -79,6 +81,142 @@ def test_compose_emits_local_source_as_string( assert plugin["description"] == "A locally vendored tool." assert plugin["version"] == "0.1.0" assert plugin["homepage"] == "https://example.com/local-tool" + assert "category" not in plugin + + +def test_compose_codex_marketplace_includes_local_and_remote_plugins(tmp_path: Path) -> None: + _write( + tmp_path / "apm.yml", + """\ + name: codex-marketplace + description: A project. + version: 1.0.0 + marketplace: + owner: + name: ACME + outputs: [codex] + packages: + - name: local-tool + source: ./plugins/local-tool + version: 0.1.0 + category: Productivity + - name: remote-tool + source: acme/remote-tool + ref: v1.0.0 + category: Developer Tools + - name: remote-subdir-tool + source: acme/monorepo + subdir: plugins/remote-subdir-tool + ref: v2.0.0 + category: Coding + """, + ) + config = load_marketplace_config(tmp_path) + builder = MarketplaceBuilder.from_config(config, tmp_path, BuildOptions(offline=True)) + local_entry = next(p for p in config.packages if p.is_local) + remote_entry = next(p for p in config.packages if p.name == "remote-tool") + remote_subdir_entry = next(p for p in config.packages if p.name == "remote-subdir-tool") + resolved = [ + builder._resolve_entry(local_entry), + # Construct the remote resolved shape directly; this test is about + # Codex composition, not git ref resolution. + ResolvedPackage( + name=remote_entry.name, + source_repo=remote_entry.source, + subdir=remote_entry.subdir, + ref="v1.0.0", + sha="a" * 40, + requested_version=None, + tags=(), + is_prerelease=False, + ), + ResolvedPackage( + name=remote_subdir_entry.name, + source_repo=remote_subdir_entry.source, + subdir=remote_subdir_entry.subdir, + ref="v2.0.0", + sha="b" * 40, + requested_version=None, + tags=(), + is_prerelease=False, + ), + ] + + doc, warnings = builder.compose_codex_marketplace_json(resolved) + + assert doc == { + "name": "codex-marketplace", + "interface": {"displayName": "codex-marketplace"}, + "plugins": [ + { + "name": "local-tool", + "source": {"source": "local", "path": "./plugins/local-tool"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Productivity", + }, + { + "name": "remote-tool", + "source": { + "source": "url", + "url": "acme/remote-tool", + "ref": "v1.0.0", + "sha": "a" * 40, + }, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Developer Tools", + }, + { + "name": "remote-subdir-tool", + "source": { + "source": "git-subdir", + "url": "acme/monorepo", + "path": "plugins/remote-subdir-tool", + "ref": "v2.0.0", + "sha": "b" * 40, + }, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Coding", + }, + ], + } + assert warnings == () + + +def test_write_codex_output_profile(tmp_path: Path) -> None: + _write( + tmp_path / "apm.yml", + """\ + name: codex-marketplace + version: 1.0.0 + marketplace: + owner: + name: ACME + outputs: [codex] + codex: + output: .agents/plugins/marketplace.json + packages: + - name: local-tool + source: ./plugins/local-tool + category: Productivity + """, + ) + config = load_marketplace_config(tmp_path) + builder = MarketplaceBuilder.from_config(config, tmp_path, BuildOptions(offline=True)) + local_entry = config.packages[0] + resolved = (builder._resolve_entry(local_entry),) + + report = builder.write_output( + MARKETPLACE_OUTPUTS["codex"], + resolved, + tmp_path / ".agents" / "plugins" / "marketplace.json", + ) + + assert report.warnings == () + assert report.output_path == tmp_path / ".agents" / "plugins" / "marketplace.json" + assert report.resolved == resolved + text = report.output_path.read_text(encoding="utf-8") + assert '"source": "local"' in text + assert '"path": "./plugins/local-tool"' in text def test_compose_inherited_top_level_omits_description_and_version(