diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5a8c3d60..ea82c3ad7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Agent primitives (`*.agent.md`) accept an optional `user-invocable: false` frontmatter field (default `true`; `visibility: internal` is an accepted alias). APM preserves the metadata so the agent is intended to be reachable programmatically only (for example via `handoffs:` or subagent calls); picker exclusion and that reachability take effect only when the receiving harness supports them. Verbatim-copy targets preserve the field; Codex and Kiro warn instead of silently dropping it. -- by @richard-fowles-epam (#3132) +- `apm pack` can emit a Copilot CLI marketplace (`marketplace.outputs: {copilot: {}}`), preserving real local paths and remote repository/subdirectory identities, tag refs, and full commit pins without changing Claude/Codex defaults. -- by @aryansk (#2600) - New `grok-bot` install target deploys skill packages to `agent-data/workflows//` for Grok Bot at project and user (`--global`) scope; the `grok-bot` target accepts packages declaring `agent-skills`, and an explicit `--target` fails only when no dependency in the install has a compatible target or a package named on the command line (and its whole subtree) has none; other filtered dependencies warn. (#3106) ### Changed 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 7e805328ac..119df503e3 100644 --- a/docs/src/content/docs/producer/publish-to-a-marketplace.md +++ b/docs/src/content/docs/producer/publish-to-a-marketplace.md @@ -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 @@ -85,7 +88,7 @@ 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 @@ -93,6 +96,9 @@ marketplace: 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 @@ -215,6 +221,31 @@ 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 +format](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#plugin-source-types) +(`.github/plugin/marketplace.json` by default). It has no `category` +requirement. Local `plugins[].source` values keep their declared +relative paths, such as `"./plugins/demo"`. Remote sources use +`{"source":"github","repo":"owner/repo"}` or +`{"source":"url","url":"https://host/team/repo.git"}`, with the +repository subdirectory in `path` when present. Resolved tag refs +and full 40-character commit pins are retained as `ref` and `sha`; +annotated tags use the peeled commit SHA, not the tag-object SHA. +A raw commit pin is emitted only as `sha`, never as a cloneable `ref`. +Unrepresentable sources or invalid pins fail explicitly rather than +inventing a local path. Existing branch restrictions remain unchanged. +Marketplace description and version live under `metadata`. + +Select both formats with `outputs: {claude: {}, copilot: {}}`. +Set `outputs.copilot.path` to change the Copilot destination; +`copilot.output` takes precedence over that map path, and +`apm pack --marketplace-path copilot=dist/copilot.json` overrides both. +Each output format also declares an +`APM_MARKETPLACE__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 @@ -269,8 +300,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. diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index 434fc6ed93..2a14b5164c 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -139,6 +139,8 @@ marketplace: claude: {} codex: path: ./build/codex-marketplace.json + copilot: + path: ./build/copilot-marketplace.json ``` ### Preview without writing @@ -209,9 +211,24 @@ 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__PATH` environment variable name (for example `APM_MARKETPLACE_COPILOT_PATH`); it is validated at startup but not yet consumed by `apm pack`. + +Copilot output preserves local `plugins[].source` paths as strings. Remote +entries use the [supported GitHub or Git URL source objects](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#plugin-source-types): +`{source: "github", repo: "owner/repo"}` or `{source: "url", url: "..."}`, +with optional `path`, `ref`, and full 40-character commit `sha`. Annotated +tag pins use their peeled commit, while a raw commit is emitted as `sha` +without `ref`. Unrepresentable inputs fail instead of becoming invented +local paths. Existing branch restrictions still apply. Marketplace +description/version are nested under `metadata`; plugin description/version +remain on each plugin. There is no `category` requirement. The default +remains Claude-only unless another output is selected. + +For Copilot path overrides, `--marketplace-path copilot=PATH` wins over +`marketplace.copilot.output`, then `marketplace.outputs.copilot.path`, +then `.github/plugin/marketplace.json`. Remote Claude entries can inherit `description` and `version` from their own `apm.yml`. If APM cannot fetch that metadata, normal packing writes the artifact diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 47d2f15dd6..b49d3954e9 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -365,6 +365,16 @@ Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update | `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). A `dependencies:` mapping, including `dependencies: {}`, produces a bundle of local package content; omitted or null `dependencies:` does not. Default output (no format flag) is a Claude Code plugin directory. Pass `--format agent-plugin` to opt into a portable Agent Plugins v1 bundle instead -- strict portable core only (root `plugin.json`, `skills/`, root `mcp.json` written even when empty; no `agents/`, `commands/`, `instructions/`, `extensions/`, `hooks/`, or LSP payload). That bundle build fails before any output is written if the source project has non-portable agents/commands/instructions/extensions/hooks/LSP, naming the surfaces and pointing to `--format claude-plugin` (and to configuring LSP in the target directly, since neither pack format carries it). A packed Agent Plugin installs through the declarative route: declare it as a dependency in `apm.yml` and run `apm install --target copilot`, which keeps the unit whole under `apm_modules/` and registers it without locating or executing Copilot; stable Copilot CLI 1.0.81 or newer is required when loading the projection. The imperative local-bundle route still fails closed for Agent Plugin bundles. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Dependency content is packed **exclusively** from lockfile-attested `deployed_files` (in every bundle format); the `apm_modules` cache is never packed. Each file is verified against its `deployed_file_hashes` SHA-256 before inclusion, so a file tampered after `apm install` (hash mismatch) or deleted (missing on disk) fails the pack with a message pointing at `apm install`; files with no recorded hash (older lockfiles) pack unverified. Dependency hooks-config / MCP-config is not attested, so it is not packed -- `apm pack` warns (`[!]`) and names the dependency (first-party root hooks/MCP are still packed). Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast; `--check-clean` is always read-only and never writes pack outputs. The version gate reads a local package's `apm.yml` first; Plugin collections without `apm.yml` use `plugin.json`'s `version`. Invalid or versionless `apm.yml` fails closed, and the fallback likewise rejects malformed or non-object JSON and a missing or blank version. When `apm.yml` declares `target: claude` or `target: copilot` (or the plural `targets:` equivalent), `apm pack` also generates an ecosystem-specific `plugin.json`: `.claude-plugin/plugin.json` for Claude (includes `mcpServers` from `.mcp.json` if present) and `.github/plugin/plugin.json` for Copilot (omits `mcpServers`). An existing file at the target path is preserved (a warning is emitted and the write is skipped) unless `--force` is passed; `--dry-run` prevents writes. Credential-bearing keys and secret-shaped values in `.mcp.json` are stripped recursively at any depth from the Claude manifest before writing, so a committed manifest never leaks secrets (see the apm pack reference, `reference/cli/pack/#credential-stripping-claude-mcpservers`). | `-o PATH`, `--archive` (produce a `.zip` archive instead of a directory; changed from `.tar.gz`), `--archive-format [zip\|tar.gz]` (default `zip`; use `tar.gz` for smaller legacy CI artifacts; only active with `--archive`), `--dry-run`, `--format [plugin\|agent-plugin\|claude\|claude-plugin\|apm]` (`agent-plugin` is the sole selector for the Agent Plugin bundle; `plugin` is a compatibility alias for the Claude plugin bundle, not for `agent-plugin`; `claude`/`claude-plugin` also select the Claude plugin bundle; `apm` selects the legacy APM layout; default `claude-plugin`), `--claude-plugin` (shortcut for `--format claude-plugin`; passing more than one of `--claude-plugin`/`--format` is a usage error), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (read-only release gate: regenerate-and-diff against the effective marketplace path, including `--marketplace-path` overrides; never writes pack outputs; exit 4 on drift). `-t/--target` is **deprecated** (warn only). Exit codes: `0` success, `1` build/runtime error, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. | | `apm unpack BUNDLE` | **[Deprecated]** Extract a bundle. Use `apm install ` instead -- it deploys directly with integrity verification and target resolution. | `-o PATH`, `--skip-verify`, `--force`, `--dry-run` | +For Copilot marketplace output, select `marketplace.outputs: {copilot: {}}` +(add `claude: {}` for both). The default path is +`.github/plugin/marketplace.json`; `--marketplace-path copilot=PATH` +overrides `marketplace.copilot.output`, then `marketplace.outputs.copilot.path`. +`APM_MARKETPLACE_COPILOT_PATH` is reserved, not consumed. Local source strings +keep real paths; remote GitHub/Git URL objects retain repository identity, +subdirectory, tag ref and full commit SHA. Raw commit pins omit `ref`; +annotated tags use their peeled commit. Invalid pins/unrepresentable sources +fail explicitly. Existing branch restrictions and Claude-only defaults remain. + For marketplace publishing, `--strict-metadata` preflights every selected marketplace output before any artifact write and exits `5` if remote metadata is unavailable. `--check-clean` also exits `4` without comparing bytes when @@ -430,7 +440,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 ` | 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 ` | 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 ` | Remove a plugin entry from `marketplace.plugins` | `--yes` | diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index cd900b1ed6..c75b843fb7 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -911,6 +911,33 @@ 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: {}, copilot: {}}`, optionally with a +per-format `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`. No `category` + requirement. Local `plugins[].source` strings preserve real paths, + e.g. `"./plugins/demo"`. Remote sources use GitHub + `{source: "github", repo: "owner/repo"}` or Git URL + `{source: "url", url: "..."}` objects with optional `path`, `ref`, + and full 40-character commit `sha`. Annotated tags use the peeled + commit; raw commit pins omit `ref`. Invalid pins or unrepresentable + sources fail explicitly. Marketplace description/version are nested + under `metadata`. Existing branch restrictions stay unchanged. + Path precedence is CLI `--marketplace-path copilot=PATH`, then + `marketplace.copilot.output`, then `marketplace.outputs.copilot.path`, + then the default. `APM_MARKETPLACE_COPILOT_PATH` is reserved and + validated but not consumed; do not use it as an override. + +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 diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index 9e273a39b6..c5781e612f 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -141,6 +141,7 @@ class ResolvedPackage: # diagnostics use the producer's convention without re-reading apm.yml. effective_tag_pattern: str = "" curator_metadata: tuple[tuple[str, str], ...] = () + commit_sha: str | None = None # peeled commit pin; legacy outputs keep sha unchanged @dataclass(frozen=True) @@ -895,6 +896,7 @@ def _resolve_explicit_ref( subdir=entry.subdir, ref=tag_name, sha=remote_ref.sha, + commit_sha=remote_ref.peeled_sha or remote_ref.sha, requested_version=entry.version, tags=entry.tags, is_prerelease=sv.is_prerelease if sv else False, @@ -918,6 +920,7 @@ def _resolve_explicit_ref( subdir=entry.subdir, ref=short, sha=remote_ref.sha, + commit_sha=remote_ref.peeled_sha or remote_ref.sha, requested_version=entry.version, tags=entry.tags, is_prerelease=sv.is_prerelease if sv else False, @@ -938,6 +941,7 @@ def _resolve_explicit_ref( subdir=entry.subdir, ref=ref_text, sha=remote_ref.sha, + commit_sha=remote_ref.peeled_sha or remote_ref.sha, requested_version=entry.version, tags=entry.tags, is_prerelease=False, @@ -996,6 +1000,7 @@ def _resolve_version_range( # Pick highest candidates.sort(key=lambda c: c[0], reverse=True) best_sv, best_tag, best_sha = candidates[0] + selected_ref = next(ref for ref in refs if ref.name == f"refs/tags/{best_tag}") return ResolvedPackage( name=entry.name, @@ -1003,6 +1008,7 @@ def _resolve_version_range( subdir=entry.subdir, ref=best_tag, sha=best_sha, + commit_sha=selected_ref.peeled_sha or best_sha, requested_version=version_range, tags=entry.tags, is_prerelease=best_sv.is_prerelease, diff --git a/src/apm_cli/marketplace/output_mappers.py b/src/apm_cli/marketplace/output_mappers.py index ad4c8f45ff..653c60886f 100644 --- a/src/apm_cli/marketplace/output_mappers.py +++ b/src/apm_cli/marketplace/output_mappers.py @@ -11,10 +11,15 @@ from abc import ABC, abstractmethod from collections import OrderedDict from dataclasses import dataclass +from pathlib import PureWindowsPath from typing import TYPE_CHECKING, Any +from apm_cli.utils.github_host import is_full_commit_sha +from apm_cli.utils.path_security import validate_path_segments + from .diagnostics import BuildDiagnostic from .errors import BuildError +from .source_identity import parse_marketplace_source _RE_NON_ALNUM = re.compile(r"[^a-z0-9]") _RE_MULTI_DASH = re.compile(r"-{2,}") @@ -328,9 +333,76 @@ def compose( return MapperResult(doc, (), tuple(name_diagnostics)) +class CopilotMarketplaceMapper(MarketplaceOutputMapper): + """Emit nested marketplace metadata and local or remote Copilot sources.""" + + 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 + + diagnostics = list(name_diagnostics) + plugins: list[dict[str, Any]] = [] + for pkg in resolved: + entry = entry_by_name.get(pkg.name) + if entry is None: + raise BuildError( + f"Copilot package '{pkg.name}' has no configured source. " + "Add it to marketplace.packages before packing.", + package=pkg.name, + ) + plugin: dict[str, Any] = OrderedDict({"name": pkg.name}) + meta = remote_metadata.get(pkg.name, {}) + for field, value in ( + ("description", entry.description), + ( + "version", + entry.version if entry.is_local or _is_display_version(entry.version) else None, + ), + ): + _apply_field_with_precedence( + plugin, + diagnostics, + field=field, + entry_value=value, + meta_value=meta.get(field), + pkg_name=pkg.name, + source_label="package apm.yml" if entry.is_local else "remote", + ) + plugin["source"] = _copilot_source(entry, pkg) + plugins.append(plugin) + + doc["plugins"] = plugins + return MapperResult(doc, (), tuple(diagnostics)) + + MARKETPLACE_OUTPUT_MAPPERS: dict[str, MarketplaceOutputMapper] = { "claude": ClaudeMarketplaceMapper(), "codex": CodexMarketplaceMapper(), + "copilot": CopilotMarketplaceMapper(), } @@ -383,6 +455,69 @@ def _codex_source(entry: PackageEntry, pkg: ResolvedPackage) -> dict[str, Any]: return source_obj +def _copilot_source( + entry: PackageEntry, + pkg: ResolvedPackage, +) -> str | dict[str, Any]: + """Preserve local paths or represent a remote Git identity without fetching.""" + if entry.is_local: + return entry.source + + remote_url = _remote_source_url(pkg) + try: + identity = parse_marketplace_source(remote_url or pkg.source_repo) + if identity.kind == "local": + raise ValueError("remote package resolved to a local source") + except ValueError as exc: + raise BuildError( + f"Copilot package '{pkg.name}' has an unrepresentable remote source. " + "Set a valid repository source in marketplace.packages.", + package=pkg.name, + ) from exc + + source: dict[str, Any] = ( + {"source": "github", "repo": pkg.source_repo} + if remote_url is None and identity.host == "github.com" + else {"source": "url", "url": identity.url} + ) + if pkg.subdir: + try: + path = PureWindowsPath(pkg.subdir) + if path.drive or path.root or "\\" in pkg.subdir: + raise ValueError("repository subdirectory must be a relative POSIX path") + validate_path_segments(pkg.subdir, allow_current_dir=True) + except ValueError as exc: + raise BuildError( + f"Copilot package '{pkg.name}' requires a relative repository subdirectory. " + "Correct its subdir and repack.", + package=pkg.name, + ) from exc + source["path"] = pkg.subdir + + commit_sha = pkg.commit_sha or pkg.sha + if commit_sha and not is_full_commit_sha(commit_sha): + raise BuildError( + f"Copilot package '{pkg.name}' requires a full 40-character commit SHA. " + "Use a full commit pin or a resolvable branch/tag ref and repack.", + package=pkg.name, + ) + if is_full_commit_sha(pkg.ref): + if commit_sha and commit_sha.lower() != pkg.ref.lower(): + raise BuildError( + f"Copilot package '{pkg.name}' has conflicting commit pins. " + "Correct its ref and repack.", + package=pkg.name, + ) + # Git clone's branch/tag ref is not a raw commit pin. + commit_sha = pkg.ref + elif pkg.ref: + source["ref"] = pkg.ref + + if commit_sha: + source["sha"] = commit_sha + return source + + def _apply_field_with_precedence( plugin: dict[str, Any], diagnostics: list[BuildDiagnostic], diff --git a/src/apm_cli/marketplace/output_profiles.py b/src/apm_cli/marketplace/output_profiles.py index adac2d608f..89cb451ca1 100644 --- a/src/apm_cli/marketplace/output_profiles.py +++ b/src/apm_cli/marketplace/output_profiles.py @@ -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, ) } diff --git a/src/apm_cli/marketplace/ref_resolver.py b/src/apm_cli/marketplace/ref_resolver.py index 04e94660c1..3160d495f8 100644 --- a/src/apm_cli/marketplace/ref_resolver.py +++ b/src/apm_cli/marketplace/ref_resolver.py @@ -102,6 +102,7 @@ class RemoteRef: name: str # e.g. "refs/tags/v1.2.0" or "refs/heads/main" sha: str # 40-char hex SHA + peeled_sha: str | None = None # commit behind an annotated tag, when advertised class _RemoteAttemptError(RuntimeError): @@ -172,6 +173,7 @@ def __len__(self) -> int: def _parse_ls_remote_output(output: str) -> list[RemoteRef]: """Parse ``git ls-remote`` stdout into a list of ``RemoteRef``.""" refs: list[RemoteRef] = [] + peeled: dict[str, str] = {} for line in output.splitlines(): line = line.strip() if not line: @@ -182,11 +184,11 @@ def _parse_ls_remote_output(output: str) -> list[RemoteRef]: sha, refname = parts[0].strip(), parts[1].strip() if not _SHA_RE.match(sha): continue - # Skip peeled tag objects (^{}) if refname.endswith("^{}"): + peeled[refname[:-3]] = sha continue refs.append(RemoteRef(name=refname, sha=sha)) - return refs + return [RemoteRef(name=ref.name, sha=ref.sha, peeled_sha=peeled.get(ref.name)) for ref in refs] class RefResolver: diff --git a/src/apm_cli/marketplace/yml_schema.py b/src/apm_cli/marketplace/yml_schema.py index 2f3c486426..ea6c31ca90 100644 --- a/src/apm_cli/marketplace/yml_schema.py +++ b/src/apm_cli/marketplace/yml_schema.py @@ -250,6 +250,7 @@ def _parse_author(raw: Any, index: int) -> dict[str, str] | None: "metadata", "build", "codex", + "copilot", "packages", "versioning", } @@ -271,6 +272,12 @@ def _parse_author(raw: Any, index: int) -> dict[str, str] | None: } ) +_COPILOT_KEYS = frozenset( + { + "output", + } +) + # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- @@ -324,6 +331,13 @@ class MarketplaceCodexConfig: output: str = MARKETPLACE_OUTPUTS["codex"].default_output +@dataclass(frozen=True) +class MarketplaceCopilotConfig: + """Copilot-specific marketplace output configuration.""" + + output: str = MARKETPLACE_OUTPUTS["copilot"].default_output + + @dataclass(frozen=True) class PackageEntry: """A single entry in the ``packages`` list. @@ -415,6 +429,7 @@ class MarketplaceConfig: outputs: tuple[str, ...] = ("claude",) claude: MarketplaceClaudeConfig = field(default_factory=MarketplaceClaudeConfig) codex: MarketplaceCodexConfig = field(default_factory=MarketplaceCodexConfig) + copilot: MarketplaceCopilotConfig = field(default_factory=MarketplaceCopilotConfig) metadata: dict[str, Any] = field(default_factory=dict) build: MarketplaceBuild = field(default_factory=MarketplaceBuild) versioning: MarketplaceVersioning = field(default_factory=MarketplaceVersioning) @@ -690,6 +705,26 @@ def _parse_codex(raw: Any) -> MarketplaceCodexConfig: return MarketplaceCodexConfig(output=output) +def _parse_copilot(raw: Any) -> MarketplaceCopilotConfig: + """Parse and validate the optional ``marketplace.copilot`` block.""" + if raw is None: + return MarketplaceCopilotConfig() + if not isinstance(raw, dict): + raise MarketplaceYmlError("'copilot' must be a mapping") + _check_unknown_keys(raw, _COPILOT_KEYS, context="copilot") + + output = raw.get("output", MARKETPLACE_OUTPUTS["copilot"].default_output) + if not isinstance(output, str) or not output.strip(): + raise MarketplaceYmlError("'copilot.output' must be a non-empty string") + output = output.strip() + try: + validate_path_segments(output, context="copilot.output") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + return MarketplaceCopilotConfig(output=output) + + def _parse_outputs( raw: Any, warnings_sink: list[str] | None = None, @@ -1252,11 +1287,15 @@ def _build_config( # -- codex output -- codex = _parse_codex(marketplace_dict.get("codex")) + # -- copilot output -- + copilot = _parse_copilot(marketplace_dict.get("copilot")) + # -- Sibling-vs-map conflict detection (A1: sibling wins) -- # Only fire when the user EXPLICITLY set a sibling block AND the map # also has an explicit path. Default/absent sibling is not a conflict. has_explicit_claude = marketplace_dict.get("claude") is not None has_explicit_codex = marketplace_dict.get("codex") is not None + has_explicit_copilot = marketplace_dict.get("copilot") is not None final_specs_list = list(output_specs) for i, spec in enumerate(final_specs_list): @@ -1266,6 +1305,8 @@ def _build_config( sibling_path = claude.output elif spec.name == "codex" and has_explicit_codex and codex.output != spec.path: sibling_path = codex.output + elif spec.name == "copilot" and has_explicit_copilot and copilot.output != spec.path: + sibling_path = copilot.output if sibling_path is not None: warnings_sink.append( f"marketplace.outputs.{spec.name}.path ('{spec.path}') " @@ -1330,6 +1371,7 @@ def _build_config( outputs=outputs, claude=claude, codex=codex, + copilot=copilot, metadata=metadata, build=build, source_base=source_base, diff --git a/tests/fixtures/schemas/copilot-marketplace-source.schema.json b/tests/fixtures/schemas/copilot-marketplace-source.schema.json new file mode 100644 index 0000000000..6cd6601e6b --- /dev/null +++ b/tests/fixtures/schemas/copilot-marketplace-source.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "APM-owned test subset derived from GitHub Copilot CLI plugin-source-types documentation, retrieved 2026-10-15. Not an official upstream schema or OpenAPM specification. https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#plugin-source-types", + "oneOf": [ + {"type": "string", "minLength": 1}, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "repo"], + "properties": { + "source": {"const": "github"}, + "repo": {"type": "string", "pattern": "^[^/]+/[^/]+$"}, + "ref": {"type": "string", "minLength": 1}, + "sha": {"type": "string", "pattern": "^[0-9a-fA-F]{40}$"}, + "path": {"type": "string", "minLength": 1} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "url"], + "properties": { + "source": {"const": "url"}, + "url": {"type": "string", "minLength": 1}, + "ref": {"type": "string", "minLength": 1}, + "sha": {"type": "string", "pattern": "^[0-9a-fA-F]{40}$"}, + "path": {"type": "string", "minLength": 1} + } + } + ] +} diff --git a/tests/integration/test_copilot_marketplace_pack.py b/tests/integration/test_copilot_marketplace_pack.py new file mode 100644 index 0000000000..6e6e993aeb --- /dev/null +++ b/tests/integration/test_copilot_marketplace_pack.py @@ -0,0 +1,314 @@ +"""Exercise real pack/config/builder/mapping with a hermetic Git transport.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import pytest +from click.testing import CliRunner +from jsonschema import Draft7Validator + +from apm_cli.commands.pack import pack_cmd +from apm_cli.marketplace.builder import MarketplaceBuilder, MetadataEnrichmentOutcome +from apm_cli.marketplace.ref_resolver import RefResolver, RemoteRef +from apm_cli.utils import git_env +from apm_cli.utils.yaml_io import dump_yaml + +pytestmark = pytest.mark.component +DEFAULT_OUTPUT = ".github/plugin/marketplace.json" + + +def _manifest(root: Path, **marketplace: Any) -> None: + config = { + "name": "copilot-catalog", + "version": "1.0.0", + "description": "Catalog", + "marketplace": { + "owner": {"name": "Acme", "email": "plugins@acme.test"}, + "outputs": {"copilot": {}}, + "packages": [ + { + "name": "demo", + "source": "./plugins/demo", + "description": "Demo", + "version": "1.0.0", + } + ], + **marketplace, + }, + } + if config["marketplace"]["outputs"] is None: + del config["marketplace"]["outputs"] + dump_yaml(config, root / "apm.yml") + plugin = root / "plugins" / "demo" + plugin.mkdir(parents=True, exist_ok=True) + dump_yaml({"name": "demo", "version": "1.0.0", "description": "Demo"}, plugin / "apm.yml") + + +def _read_output(root: Path, path: str = DEFAULT_OUTPUT) -> dict: + doc = json.loads((root / path).read_text(encoding="utf-8")) + schema = ( + Path(__file__).parents[1] + / "fixtures" + / "schemas" + / "copilot-marketplace-source.schema.json" + ) + validator = Draft7Validator(json.loads(schema.read_text(encoding="utf-8"))) + for plugin in doc["plugins"]: + validator.validate(plugin["source"]) + return doc + + +@pytest.mark.parametrize( + ("outputs", "sibling", "override", "expected"), + [ + ({"copilot": {}}, None, None, DEFAULT_OUTPUT), + ({"copilot": {"path": "dist/from-map.json"}}, None, None, "dist/from-map.json"), + ( + {"copilot": {"path": "dist/from-map.json"}}, + "dist/from-sibling.json", + None, + "dist/from-sibling.json", + ), + ( + {"copilot": {"path": "dist/from-map.json"}}, + "dist/from-sibling.json", + "dist/from-cli.json", + "dist/from-cli.json", + ), + ], +) +def test_pack_copilot_output_path_precedence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + outputs: dict, + sibling: str | None, + override: str | None, + expected: str, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("APM_MARKETPLACE_COPILOT_PATH", "reserved-not-consumed.json") + extra = {"copilot": {"output": sibling}} if sibling else {} + _manifest(tmp_path, outputs=outputs, **extra) + args = ["--json"] + if override: + args += ["--marketplace-path", f"copilot={override}"] + result = CliRunner().invoke(pack_cmd, args) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["ok"] is True + doc = _read_output(tmp_path, expected) + assert doc["metadata"] == {"description": "Catalog", "version": "1.0.0"} + assert doc["owner"] == {"name": "Acme", "email": "plugins@acme.test"} + assert "description" not in doc and "version" not in doc + assert doc["plugins"][0]["source"] == "./plugins/demo" + assert not (tmp_path / "reserved-not-consumed.json").exists() + + +def test_pack_still_defaults_to_claude_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _manifest(tmp_path, outputs=None) + result = CliRunner().invoke(pack_cmd, ["--json"]) + assert result.exit_code == 0, result.output + assert (tmp_path / ".claude-plugin/marketplace.json").is_file() + assert not (tmp_path / DEFAULT_OUTPUT).exists() + _manifest(tmp_path, outputs={"claude": {}, "copilot": {}}) + selected = CliRunner().invoke(pack_cmd, ["--marketplace=copilot", "--json"]) + assert selected.exit_code == 0, selected.output + assert _read_output(tmp_path)["plugins"][0]["source"] == "./plugins/demo" + + +def test_pack_copilot_and_existing_outputs_remain_distinct( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _manifest( + tmp_path, + outputs={"claude": {}, "copilot": {}, "codex": {}}, + packages=[{"name": "demo", "source": "./plugins/demo", "category": "testing"}], + ) + result = CliRunner().invoke(pack_cmd, ["--json"]) + assert result.exit_code == 0, result.output + copilot = _read_output(tmp_path) + claude = json.loads((tmp_path / ".claude-plugin/marketplace.json").read_text()) + codex = json.loads((tmp_path / ".agents/plugins/marketplace.json").read_text()) + assert copilot["plugins"][0]["source"] == "./plugins/demo" + assert copilot["plugins"][0]["description"] == "Demo" + assert copilot["plugins"][0]["version"] == "1.0.0" + assert claude["plugins"][0]["source"] == "./plugins/demo" + assert codex["plugins"][0]["source"] == {"source": "local", "path": "./plugins/demo"} + assert "category" not in copilot["plugins"][0] + assert codex["plugins"][0]["category"] == "testing" + + +@pytest.fixture +def git_remote(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[str, str]: + repo = tmp_path / "remote" + repo.mkdir() + git = git_env.get_git_executable() + + def run(*args: str) -> str: + return subprocess.run( + [ + git, + "-C", + str(repo), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + *args, + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + run("init", "--initial-branch=main") + (repo / "plugin.json").write_text('{"name":"demo","version":"1.0.0"}\n', encoding="utf-8") + run("add", "plugin.json") + run("commit", "-m", "Fixture") + run("tag", "-a", "v1.0.0", "-m", "Annotated release") + run("tag", "lightweight") + commit, tag_object = run("rev-parse", "HEAD"), run("rev-parse", "v1.0.0") + real_ls_remote = git_env.git_remote_refs + + def local_transport(url: str, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess: + return real_ls_remote(str(repo), *args, **kwargs) + + monkeypatch.setattr(git_env, "git_remote_refs", local_transport) + return commit, tag_object + + +@pytest.mark.parametrize( + "source", ["acme/demo", "ghe.example.com/acme/demo", "https://gitlab.example.com/team/demo.git"] +) +@pytest.mark.parametrize( + "ref", ["v1.0.0", "refs/tags/v1.0.0", "lightweight", "main", "commit", None] +) +@pytest.mark.parametrize("subdir", [None, "plugins/demo"]) +def test_pack_remote_sources_preserve_commit_not_tag_object( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + git_remote: tuple[str, str], + source: str, + ref: str | None, + subdir: str | None, +) -> None: + commit, tag_object = git_remote + monkeypatch.chdir(tmp_path) + package = { + "name": "demo", + "source": source, + "version": "1.0.0", + "description": "Demo", + } + if subdir: + package["subdir"] = subdir + if ref: + package["ref"] = commit if ref == "commit" else ref + _manifest(tmp_path, outputs={"copilot": {}, "claude": {}}, packages=[package]) + result = CliRunner().invoke(pack_cmd, ["--json"]) + if ref == "main": + assert result.exit_code != 0 + assert "branch/HEAD ref" in result.output + assert not (tmp_path / DEFAULT_OUTPUT).exists() + return + assert result.exit_code == 0, result.output + emitted = _read_output(tmp_path)["plugins"][0]["source"] + if subdir: + assert emitted["path"] == subdir + else: + assert "path" not in emitted + assert emitted["sha"] == commit + assert emitted["sha"] != tag_object + if ref == "commit": + assert "ref" not in emitted + else: + assert emitted["ref"] == (ref.removeprefix("refs/tags/") if ref else "v1.0.0") + if source == "acme/demo": + assert emitted["source"] == "github" + assert emitted["repo"] == "acme/demo" + else: + assert emitted["source"] == "url" + parsed = urlparse(emitted["url"]) + expected = urlparse(source if source.startswith("https://") else f"https://{source}") + assert (parsed.scheme, parsed.hostname, parsed.path) == ( + expected.scheme, + expected.hostname, + expected.path, + ) + # Annotated-tag handling must not change the existing Claude schema/pin. + if ref in ("v1.0.0", "refs/tags/v1.0.0", None): + claude = json.loads((tmp_path / ".claude-plugin/marketplace.json").read_text()) + assert claude["plugins"][0]["source"]["sha"] == tag_object + stable = (tmp_path / DEFAULT_OUTPUT).read_bytes() + check = CliRunner().invoke(pack_cmd, ["--json", "--check-clean"]) + assert check.exit_code == 0, check.output + assert (tmp_path / DEFAULT_OUTPUT).read_bytes() == stable + + +def test_pack_rejects_unrepresentable_commit_without_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _manifest( + tmp_path, + packages=[ + { + "name": "demo", + "source": "acme/demo", + "ref": "v1.0.0", + "version": "1.0.0", + "description": "Demo", + } + ], + ) + monkeypatch.setattr( + RefResolver, + "list_remote_refs", + lambda *a, **kw: [RemoteRef("refs/tags/v1.0.0", "short-sha")], + ) + result = CliRunner().invoke(pack_cmd, ["--json"]) + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["ok"] is False + assert any("full 40-character commit SHA" in error["message"] for error in payload["errors"]) + assert not (tmp_path / DEFAULT_OUTPUT).exists() + + +@pytest.mark.parametrize("curator_description", [None, "Curated description"]) +def test_pack_copilot_remote_metadata_is_certifiable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, curator_description: str | None +) -> None: + monkeypatch.chdir(tmp_path) + package = {"name": "demo", "source": "acme/demo", "ref": "a" * 40} + if curator_description: + package["description"] = curator_description + _manifest(tmp_path, packages=[package]) + monkeypatch.setattr( + MarketplaceBuilder, + "_fetch_remote_metadata_outcome", + lambda self, pkg: MetadataEnrichmentOutcome( + pkg.name, + "fetched", + values=(("description", "Remote description"), ("version", "2.0.0")), + ), + ) + result = CliRunner().invoke(pack_cmd, ["--json", "--strict-metadata"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["metadata_enrichment"]["certifiable"] is True + assert payload["metadata_enrichment"]["outcomes"][0]["status"] == "fetched" + plugin = _read_output(tmp_path)["plugins"][0] + assert plugin["description"] == (curator_description or "Remote description") + assert plugin["version"] == "2.0.0" diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index 1f12d73725..13682d9086 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -337,7 +337,7 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): version: "^1.0.0" """) -_APM_YML_BOTH_FORMATS = textwrap.dedent("""\ +_APM_YML_ALL_FORMATS = textwrap.dedent("""\ name: demo description: d version: 0.1.0 @@ -346,6 +346,7 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): outputs: claude: {} codex: {} + copilot: {} packages: - name: pkg source: acme/pkg @@ -377,7 +378,7 @@ def test_partial_coverage_lists_missing_formats(self, mock_run, runner, tmp_path @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_full_coverage_passes_silently(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - (tmp_path / "apm.yml").write_text(_APM_YML_BOTH_FORMATS, encoding="utf-8") + (tmp_path / "apm.yml").write_text(_APM_YML_ALL_FORMATS, encoding="utf-8") mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), diff --git a/tests/unit/marketplace/test_apm_yml_marketplace_loader.py b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py index b5bcea9165..9d9aa513b8 100644 --- a/tests/unit/marketplace/test_apm_yml_marketplace_loader.py +++ b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py @@ -22,12 +22,15 @@ def _write(p: Path, content: str) -> None: def test_marketplace_output_profiles_define_supported_outputs() -> None: - assert known_output_names() == {"claude", "codex"} + assert known_output_names() == {"claude", "codex", "copilot"} 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",) + assert MARKETPLACE_OUTPUTS["copilot"].mapper == "copilot" + assert MARKETPLACE_OUTPUTS["copilot"].supports_cli_output_override is False + assert MARKETPLACE_OUTPUTS["copilot"].required_package_fields == () _MIN_BLOCK_INHERIT = """\ diff --git a/tests/unit/marketplace/test_copilot_output_mapper.py b/tests/unit/marketplace/test_copilot_output_mapper.py new file mode 100644 index 0000000000..42323a793b --- /dev/null +++ b/tests/unit/marketplace/test_copilot_output_mapper.py @@ -0,0 +1,243 @@ +"""Copilot marketplace source mapping follows the documented consumer contract.""" + +from __future__ import annotations + +from dataclasses import replace +from urllib.parse import urlparse + +import pytest + +from apm_cli.marketplace.builder import ResolvedPackage +from apm_cli.marketplace.errors import BuildError +from apm_cli.marketplace.output_mappers import CopilotMarketplaceMapper +from apm_cli.marketplace.output_profiles import MARKETPLACE_OUTPUTS +from apm_cli.marketplace.yml_schema import ( + MarketplaceConfig, + MarketplaceOwner, + MarketplaceYmlError, + PackageEntry, + _parse_copilot, +) + +pytestmark = pytest.mark.unit +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def _resolved(**overrides: object) -> ResolvedPackage: + return replace( + ResolvedPackage("demo", "acme/demo", None, "v2.1.0", SHA, None, (), False), + **overrides, + ) + + +def _compose( + entry: PackageEntry, pkg: ResolvedPackage, remote_metadata: dict | None = None +) -> dict: + config = MarketplaceConfig( + name="my-marketplace", + description="Curated plugins", + version="1.0.0", + owner=MarketplaceOwner(name="Acme", email="plugins@acme.test"), + outputs=("copilot",), + packages=(entry,), + ) + return ( + CopilotMarketplaceMapper() + .compose(config=config, resolved=(pkg,), remote_metadata=remote_metadata) + .document + ) + + +def test_copilot_profile_uses_default_discovery_path() -> None: + profile = MARKETPLACE_OUTPUTS["copilot"] + assert profile.default_output == ".github/plugin/marketplace.json" + assert profile.mapper == "copilot" + assert profile.path_env_var == "APM_MARKETPLACE_COPILOT_PATH" + + +def test_copilot_mapper_nests_marketplace_metadata_and_keeps_local_source() -> None: + doc = _compose( + PackageEntry( + name="demo", + source="./plugins/demo", + version="2.1.0", + description="Demo plugin", + is_local=True, + ), + _resolved(source_repo="", ref="", sha=""), + ) + assert doc["metadata"] == {"description": "Curated plugins", "version": "1.0.0"} + assert "version" not in doc and "description" not in doc + assert doc["plugins"] == [ + { + "name": "demo", + "description": "Demo plugin", + "version": "2.1.0", + "source": "./plugins/demo", + } + ] + + +@pytest.mark.parametrize("subdir", [None, "plugins/demo", "./plugins/demo", "plugins/demo/"]) +def test_copilot_remote_github_preserves_identity_and_pins(subdir: str | None) -> None: + source = _compose( + PackageEntry(name="demo", source="acme/demo", ref="v2.1.0"), + _resolved(subdir=subdir), + )["plugins"][0]["source"] + assert source == { + "source": "github", + "repo": "acme/demo", + "ref": "v2.1.0", + "sha": SHA, + **({"path": subdir} if subdir else {}), + } + + +@pytest.mark.parametrize( + ("host", "url", "scheme", "expected_host", "path"), + [ + ("ghe.example.com", None, "https", "ghe.example.com", "/acme/demo"), + ( + None, + "https://gitlab.example.com/team/demo.git", + "https", + "gitlab.example.com", + "/team/demo.git", + ), + ( + None, + "ssh://git@git.example.com:2222/team/demo.git", + "ssh", + "git.example.com", + "/team/demo.git", + ), + ], +) +def test_copilot_remote_url_preserves_transport( + host: str | None, url: str | None, scheme: str, expected_host: str, path: str +) -> None: + source = _compose( + PackageEntry(name="demo", source="acme/demo", ref="v2.1.0"), + _resolved(host=host, source_url=url, subdir="plugins/demo"), + )["plugins"][0]["source"] + assert source["source"] == "url" + parsed = urlparse(source["url"]) + assert (parsed.scheme, parsed.hostname, parsed.path) == (scheme, expected_host, path) + if scheme == "ssh": + assert (parsed.username, parsed.port) == ("git", 2222) + assert source["ref"] == "v2.1.0" + assert source["sha"] == SHA + assert source["path"] == "plugins/demo" + + +@pytest.mark.parametrize("ref", [SHA, SHA.upper(), ""]) +def test_copilot_commit_pin_is_not_a_cloneable_ref(ref: str) -> None: + source = _compose( + PackageEntry(name="demo", source="acme/demo", ref=SHA), + _resolved(ref=ref), + )["plugins"][0]["source"] + assert "ref" not in source + assert source["sha"].lower() == SHA + assert source["repo"] == "acme/demo" + + +def test_copilot_uses_peeled_commit_not_tag_object_sha() -> None: + source = _compose( + PackageEntry(name="demo", source="acme/demo", ref="v2.1.0"), + _resolved(sha="a" * 40, commit_sha=SHA), + )["plugins"][0]["source"] + assert source["sha"] == SHA + assert source["ref"] == "v2.1.0" + + +def test_copilot_preserves_resolved_branch_ref() -> None: + source = _compose( + PackageEntry(name="demo", source="acme/demo", ref="release/stable"), + _resolved(ref="release/stable"), + )["plugins"][0]["source"] + assert source["ref"] == "release/stable" + assert source["sha"] == SHA + + +@pytest.mark.parametrize( + ("overrides", "error"), + [ + ({"source_repo": ""}, "unrepresentable remote source"), + ({"source_repo": "./invented"}, "unrepresentable remote source"), + ({"sha": "abc123"}, "full 40-character commit SHA"), + ({"ref": "b" * 40}, "conflicting commit pins"), + ({"subdir": "/absolute"}, "relative repository subdirectory"), + ({"subdir": "C:/absolute"}, "relative repository subdirectory"), + ({"subdir": "../outside"}, "relative repository subdirectory"), + ({"subdir": r"plugins\demo"}, "relative repository subdirectory"), + ], +) +def test_copilot_unrepresentable_input_fails_explicitly(overrides: dict, error: str) -> None: + with pytest.raises(BuildError, match=error): + _compose(PackageEntry(name="demo", source="acme/demo"), _resolved(**overrides)) + + +def test_copilot_mapper_omits_empty_description() -> None: + doc = _compose( + PackageEntry(name="demo", source="./plugins/demo", is_local=True), + _resolved(source_repo="", ref="", sha=""), + ) + assert "description" not in doc["plugins"][0] + + +def test_copilot_missing_configured_package_fails() -> None: + with pytest.raises(BuildError, match="no configured source"): + _compose( + PackageEntry(name="other", source="./plugins/other", is_local=True), + _resolved(), + ) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ("bad", "must be a mapping"), + ({"extra": True}, r"Unknown key\(s\) in copilot"), + ({"output": " "}, "must be a non-empty string"), + ({"output": 42}, "must be a non-empty string"), + ({"output": "../escape.json"}, "traversal"), + ], +) +def test_copilot_config_rejects_invalid_output(config: object, error: str) -> None: + with pytest.raises(MarketplaceYmlError, match=error): + _parse_copilot(config) + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + (None, ".github/plugin/marketplace.json"), + ({}, ".github/plugin/marketplace.json"), + ({"output": " dist/copilot.json "}, "dist/copilot.json"), + ], +) +def test_copilot_config_valid_output(config: dict | None, expected: str) -> None: + assert _parse_copilot(config).output == expected + + +@pytest.mark.parametrize( + ("description", "version", "expected_description", "expected_version"), + [ + (None, None, "Remote description", "2.0.0"), + ("Curator description", "3.0.0", "Curator description", "3.0.0"), + (None, "^2.0.0", "Remote description", "2.0.0"), + ], +) +def test_copilot_remote_metadata_precedence( + description: str | None, + version: str | None, + expected_description: str, + expected_version: str, +) -> None: + plugin = _compose( + PackageEntry(name="demo", source="acme/demo", description=description, version=version), + _resolved(), + {"demo": {"description": "Remote description", "version": "2.0.0"}}, + )["plugins"][0] + assert plugin["description"] == expected_description + assert plugin["version"] == expected_version diff --git a/tests/unit/marketplace/test_local_path_compose.py b/tests/unit/marketplace/test_local_path_compose.py index a509ba00fb..fcc6b776f1 100644 --- a/tests/unit/marketplace/test_local_path_compose.py +++ b/tests/unit/marketplace/test_local_path_compose.py @@ -303,6 +303,46 @@ def test_write_codex_output_profile(tmp_path: Path) -> None: assert '"path": "./plugins/local-tool"' in text +def test_write_copilot_output_profile(tmp_path: Path) -> None: + _write( + tmp_path / "apm.yml", + """\ + name: copilot-marketplace + version: 1.0.0 + marketplace: + owner: + name: ACME + outputs: [copilot] + copilot: + output: .github/plugin/marketplace.json + packages: + - name: local-tool + source: ./plugins/local-tool + description: A locally vendored tool. + """, + ) + 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["copilot"], + resolved, + tmp_path / ".github" / "plugin" / "marketplace.json", + ) + + assert report.warnings == () + assert report.output_path == tmp_path / ".github" / "plugin" / "marketplace.json" + assert report.resolved == resolved + text = report.output_path.read_text(encoding="utf-8") + # Copilot's source is a relative-path string, never the Claude/Codex + # object shape (no nested "source"/"url"/"ref"/"sha" keys). + assert '"source": "./plugins/local-tool"' in text + assert '"ref"' not in text + assert '"sha"' not in text + + def test_compose_inherited_top_level_omits_description_and_version( project_with_local: Path, ) -> None: diff --git a/tests/unit/marketplace/test_ref_resolver.py b/tests/unit/marketplace/test_ref_resolver.py index 79d4607534..7384221713 100644 --- a/tests/unit/marketplace/test_ref_resolver.py +++ b/tests/unit/marketplace/test_ref_resolver.py @@ -48,14 +48,16 @@ def test_multiple_refs(self) -> None: refs = _parse_ls_remote_output(output) assert len(refs) == 3 - def test_peeled_tag_skipped(self) -> None: - output = ( - "aaaa23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0\n" - "bbbb23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0^{}\n" - ) + @pytest.mark.parametrize("reverse", [False, True]) + def test_peeled_tag_retained_without_changing_legacy_sha(self, reverse: bool) -> None: + output = f"{'a' * 40}\trefs/tags/v1.0.0\n{'b' * 40}\trefs/tags/v1.0.0^{{}}\n" + if reverse: + output = "\n".join(reversed(output.splitlines())) refs = _parse_ls_remote_output(output) assert len(refs) == 1 assert refs[0].name == "refs/tags/v1.0.0" + assert refs[0].sha == "a" * 40 + assert refs[0].peeled_sha == "b" * 40 def test_invalid_sha_skipped(self) -> None: output = "not-a-sha\trefs/tags/v1.0.0\n"