Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm pack --check-clean` now certifies GitHub-hosted manifestless skills when `SKILL.md` has valid frontmatter at the resolved repository, ref, and subdirectory, while invalid skill metadata and missing repositories, refs, and package paths remain fail-closed. (#3064)
- Codex agent conversion preserves native `model` and `model_reasoning_effort` settings and warns about dropped metadata instead of silently losing it. (#3150)
- Cursor hooks use native v1 events and flat handlers; unsupported input and overlapping Claude imports now fail explicitly. Choose one hook route per dependency; see the [supported mappings](docs/src/content/docs/producer/author-primitives/hooks-and-commands.md#cursor-native-hooks-and-claude-import) and `openapm-v0.1.md` requirements `req-tg-016/017`. (#3149)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ apm pack --strict-metadata # require remote Claude package metadata fetches
Strict remote metadata inheritance currently uses GitHub APIs. For GitLab,
Azure DevOps, or another host, give each remote package a fixed `description`
and fixed display `version` (not a range) so `--strict-metadata` can certify it
without fetching `apm.yml`.
without fetching `apm.yml`. On GitHub, an absent remote `apm.yml` is also
certifiable when APM verifies a `SKILL.md` at the same resolved repository,
ref, and package subdirectory.

For the release-gate flags (`--check-versions`, `--check-clean`),
see [Releasing from any CI](../releasing-from-any-ci/).
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/producer/releasing-from-any-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ steps:
| 1 | runtime | Build or network error. Inspect the JSON report; rerun. |
| 2 | schema | `apm.yml` is invalid. Fix the manifest before tagging. |
| 3 | `--check-versions`| Per-package versions disagree with `marketplace.versioning.strategy`. See [Versioning strategies](../versioning-strategies/). |
| 4 | `--check-clean` | Committed `marketplace.json` does not match a fresh pack, or remote Claude package metadata was unfetchable. For drift, run `apm pack` locally, commit the diff, then re-tag. For metadata unavailability, restore the remote source or CI credentials and rerun; committing a regenerated file cannot certify unavailable metadata. |
| 4 | `--check-clean` | Committed `marketplace.json` does not match a fresh pack, or remote Claude package metadata was unfetchable. A missing `apm.yml` is accepted only when `SKILL.md` verifies a manifestless skill at the same resolved path. For drift, run `apm pack` locally, commit the diff, then re-tag. For metadata unavailability, restore the remote source or CI credentials and rerun; committing a regenerated file cannot certify unavailable metadata. |
| 5 | `--strict-metadata`| Remote Claude package metadata could not be fetched, so `apm pack` refused to write. Retry with network access, or omit `--strict-metadata` when the default warning is acceptable. |

`--check-clean` is always read-only. `--check-versions` does not suppress normal
Expand Down
13 changes: 10 additions & 3 deletions docs/src/content/docs/reference/cli/pack.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The `metadata_enrichment` object has a closed per-package status vocabulary:
|---|---|---|
| `fetched` | Remote `apm.yml` supplied metadata. | yes |
| `empty` | Remote `apm.yml` was reachable but had no description or version. | yes |
| `manifestless` | Remote `apm.yml` was absent, but `SKILL.md` had valid `name` and `description` frontmatter at the resolved path. | yes |
| `local` | Metadata came from a local package. | yes |
| `explicit` | Fixed description and version came from the marketplace entry. | yes |
| `failed` | The remote manifest could not be fetched. | no |
Expand Down Expand Up @@ -92,9 +93,15 @@ uses `marketplace_metadata_uncertifiable` and exits `4`; `--strict-metadata`
uses `metadata_incomplete` and exits `5` before writing.

GitHub-hosted packages can inherit description and version from their remote
`apm.yml`. For GitLab, Azure DevOps, and other hosts, set fixed `description`
and `version` fields on the marketplace package entry so strict checks can
certify without a GitHub metadata request.
`apm.yml`. When that file returns `404`, APM verifies `SKILL.md` at the same
resolved repository, ref, and subdirectory. A verified manifestless skill is
certifiable when its frontmatter has non-empty string `name` and `description`
fields. Malformed frontmatter, missing fields, non-string values, and
whitespace-only values remain `failed` and make `--check-clean` exit `4`, as do
a missing repository, ref, or package path.
For GitLab, Azure DevOps, and other hosts, set fixed `description` and `version`
fields on the marketplace package entry so strict checks can certify without a
GitHub metadata request.

## Examples

Expand Down
11 changes: 8 additions & 3 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,14 @@ marketplace output before any artifact write and exits `5` if remote metadata
is unavailable. `--check-clean` also exits `4` without comparing bytes when
regeneration is not certifiable. With `--json`, inspect
`metadata_enrichment.certifiable` and each outcome's `status`
(`fetched`, `empty`, `explicit`, `failed`, `offline`, or `local`). GitLab,
Azure DevOps, and other non-GitHub packages need fixed `description` and
`version` fields for strict certification.
(`fetched`, `empty`, `manifestless`, `explicit`, `failed`, `offline`, or
`local`). On GitHub, `manifestless` means `apm.yml` was absent while
`SKILL.md` with non-empty string `name` and `description` frontmatter was
verified at the same resolved repository, ref, and subdirectory. Malformed
frontmatter, missing fields, non-string values, and whitespace-only values stay
`failed` and make `--check-clean` exit `4`. GitLab, Azure DevOps, and other
non-GitHub packages need fixed `description` and `version` fields for strict
certification.

`apm install <BUNDLE-PATH>` -- when the positional argument resolves to a directory containing `plugin.json` at its root, or to a `.zip` (or legacy `.tar.gz`/`.tgz`) archive whose extracted root contains `plugin.json`, install switches to local-bundle mode: the bundle is integrity-verified against its embedded `apm.lock.yaml` (`pack.bundle_files`) and deployed into the consumer's resolved target. Root `plugin.json` routing is exclusively schema-driven: only a `plugin.json` with **no** `$schema` key follows the legacy Claude/APM path described below. If `plugin.json` declares the exact recognized Agent Plugins v1 `$schema` (`1.0.0`), the imperative bundle route fails closed instead of dissecting it, with a message pointing at the declarative dependency route (`apm.yml` + `apm install --target copilot`) that registers the plugin natively with GitHub Copilot. Any other schema-bearing `plugin.json` hard-fails rather than falling back to legacy routing: a non-string `$schema`, an Agent Plugins schema at an unsupported version, or a foreign schema id from another tool entirely all raise before any dissection is attempted. Target resolution follows the same precedence as registry installs (`--target` > `apm.yml` > directory detection); the bundle itself carries no target binding. Targets without target-native instruction deployment (opencode, codex, gemini) receive instructions staged under `apm_modules/<slug>/.apm/instructions/` and the install emits a hint to run `apm compile` to merge them. Grok Build deploys native instructions to `.grok/rules/`; run `apm compile` separately when you also want `AGENTS.md`. Other existing paths (e.g. a source-package directory without `plugin.json`) still flow through the normal local-path dependency-resolver pipeline. Files are recorded under `local_deployed_files` in the project lockfile -- `apm.yml` is **never** mutated. Honours `--target`, `--global`, `--force`, `--dry-run`, `--verbose`, plus `--as ALIAS` (log/display label only). Resolver/MCP/registry/policy flags (`--update`, `--mcp`, `--parallel-downloads`, `--allow-insecure-host`, `--skill`, ...) are rejected with a single consolidated error -- local-bundle install is an imperative deploy and bypasses those subsystems.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,21 @@ def _check_metadata_enrichment(provider: FactsProvider) -> tuple[Violation, ...]
parse=True,
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_METADATA,
_METADATA_OWNER,
(
'skill_path = f"{path_prefix}SKILL.md"',
"skill = loads_frontmatter(_request_text(url, rest=rest))",
'return MetadataEnrichmentOutcome(pkg.name, "manifestless")',
),
"marketplace metadata owner must parse manifestless skills before certification",
parse=True,
)
)
findings.extend(
_require_subs(
provider,
Expand Down
139 changes: 96 additions & 43 deletions src/apm_cli/marketplace/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

from ..utils.github_host import default_host, is_azure_devops_hostname
from ..utils.path_security import ensure_path_within
from ..utils.yaml_io import load_yaml_str
from ..utils.yaml_io import load_yaml_str, loads_frontmatter
from ._io import atomic_write
from ._shared import iter_semver_tags
from .auth_helpers import resolve_auth_for_host
Expand Down Expand Up @@ -74,10 +74,22 @@
logger = logging.getLogger(__name__)

_LOCAL_METADATA_MAX_BYTES = 64 * 1024
_METADATA_STATUSES = frozenset({"fetched", "empty", "failed", "offline", "local", "explicit"})
_CERTIFIABLE_METADATA_STATUSES = frozenset({"fetched", "empty", "local", "explicit"})
_METADATA_STATUSES = frozenset(
{"fetched", "empty", "manifestless", "failed", "offline", "local", "explicit"}
)
_CERTIFIABLE_METADATA_STATUSES = frozenset(
{"fetched", "empty", "manifestless", "local", "explicit"}
)
_CERTIFYING_METADATA_FIELDS = frozenset({"description", "version"})
MetadataEnrichmentStatus = Literal["fetched", "empty", "failed", "offline", "local", "explicit"]
MetadataEnrichmentStatus = Literal[
"fetched",
"empty",
"manifestless",
"failed",
"offline",
"local",
"explicit",
]


def _read_capped_text(resp: Any) -> str:
Expand Down Expand Up @@ -170,6 +182,14 @@ def to_json_dict(self) -> dict[str, Any]:
return payload


@dataclass(frozen=True)
class _RemoteMetadataDocument:
"""Remote manifest content or a verified manifestless skill marker."""

content: str | None
manifestless: bool = False


@dataclass(frozen=True)
class MetadataEnrichmentResult(Mapping[str, dict[str, str]]):
"""Canonical metadata enrichment state for one marketplace build.
Expand Down Expand Up @@ -1153,7 +1173,7 @@ def _fetch_remote_metadata_outcome(
self,
pkg: ResolvedPackage,
) -> MetadataEnrichmentOutcome:
"""Fetch remote metadata while preserving complete failure state.
"""Fetch remote metadata while preserving complete package state.

When a token is available for the package's host, it is included
as an ``Authorization`` header so private repos can be accessed.
Expand All @@ -1164,14 +1184,17 @@ def _fetch_remote_metadata_outcome(
first, then fall back to the GitHub REST Contents endpoint when
raw returns 404 (the private / INTERNAL repository symptom).
GHES and GHE Cloud packages use the GitHub REST API on the
package's host. For non-GitHub-class hosts, metadata enrichment
is skipped.
package's host. When ``apm.yml`` is absent, the same transport
verifies a root ``SKILL.md`` before returning the certifiable
``manifestless`` state. For non-GitHub-class hosts, metadata
enrichment is skipped.
"""
try:
from ..core.auth import AuthResolver

path_prefix = f"{pkg.subdir}/" if pkg.subdir else ""
file_path = f"{path_prefix}apm.yml"
manifest_path = f"{path_prefix}apm.yml"
skill_path = f"{path_prefix}SKILL.md"

# Resolve the effective host for this package and its
# classification. Falls back to the builder default when the
Expand Down Expand Up @@ -1202,66 +1225,95 @@ def _fetch_remote_metadata_outcome(

auth_resolver = self._auth_resolver

def _open_metadata(token: str | None, _git_env: dict[str, str]) -> str:
def _open_metadata(
token: str | None,
_git_env: dict[str, str],
) -> _RemoteMetadataDocument:
"""Fetch the manifest with the credential selected for this attempt."""
if host_kind == "ghe_cloud" and token is None:
raise PermissionError(
f"metadata fetch for '{effective_host}' requires a credential"
)
if effective_host == "github.com":
raw_url = (
f"https://raw.githubusercontent.com/{pkg.source_repo}/{pkg.sha}/{file_path}"
)
req = urllib.request.Request(raw_url) # noqa: S310

def _request_text(url: str, *, rest: bool = False) -> str:
"""Read one bounded remote document with attempt credentials."""
req = urllib.request.Request(url) # noqa: S310
if rest:
req.add_header("Accept", "application/vnd.github.raw")
if token:
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return _read_capped_text(resp)

def _is_valid_skill(url: str, *, rest: bool = False) -> bool:
"""Return whether the URL contains parseable Agent Skills metadata."""
try:
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return _read_capped_text(resp)
skill = loads_frontmatter(_request_text(url, rest=rest))
except urllib.error.HTTPError as exc:
if exc.code == 404:
return False
raise
Comment thread
yfoel marked this conversation as resolved.
name = skill.metadata.get("name")
description = skill.metadata.get("description")
return (
isinstance(name, str)
and bool(name.strip())
and isinstance(description, str)
and bool(description.strip())
)

def _rest_url(file_path: str) -> str:
"""Build a Contents API URL for the resolved package path."""
api_base = (
host_info.api_base if host_info else None
) or f"https://{effective_host}/api/v3"
return f"{api_base}/repos/{pkg.source_repo}/contents/{file_path}?ref={pkg.sha}"

if effective_host == "github.com":
raw_manifest_url = (
"https://raw.githubusercontent.com/"
f"{pkg.source_repo}/{pkg.sha}/{manifest_path}"
)
try:
return _RemoteMetadataDocument(_request_text(raw_manifest_url))
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
raw_skill_url = (
"https://raw.githubusercontent.com/"
f"{pkg.source_repo}/{pkg.sha}/{skill_path}"
)
if _is_valid_skill(raw_skill_url):
return _RemoteMetadataDocument(None, manifestless=True)
if token is None:
raise
api_base = (
host_info.api_base if host_info else None
) or "https://api.github.com"
rest_url = (
f"{api_base}/repos/{pkg.source_repo}/contents/{file_path}?ref={pkg.sha}"
)
req = urllib.request.Request(rest_url) # noqa: S310
req.add_header("Accept", "application/vnd.github.raw")
if token:
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return _read_capped_text(resp)

api_base = (
host_info.api_base if host_info else None
) or f"https://{effective_host}/api/v3"
url = f"{api_base}/repos/{pkg.source_repo}/contents/{file_path}?ref={pkg.sha}"
req = urllib.request.Request(url) # noqa: S310
req.add_header("Accept", "application/vnd.github.raw")
if token:
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
return _read_capped_text(resp)
rest_manifest_url = _rest_url(manifest_path)
try:
return _RemoteMetadataDocument(_request_text(rest_manifest_url, rest=True))
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
if _is_valid_skill(_rest_url(skill_path), rest=True):
return _RemoteMetadataDocument(None, manifestless=True)
raise

org = pkg.source_repo.split("/", 1)[0] if pkg.source_repo else None
if auth_resolver is None:
# Legacy direct callers do not enter the concurrent prefetch
# path, which initializes AuthResolver before worker threads.
raw = _open_metadata(self._github_token, {})
document = _open_metadata(self._github_token, {})
else:
unauth_first = auth_resolver.uses_public_github_anonymous_first(effective_host)
raw = auth_resolver.try_with_fallback(
document = auth_resolver.try_with_fallback(
effective_host,
_open_metadata,
org=org,
path=pkg.source_repo,
unauth_first=unauth_first,
)
data = load_yaml_str(raw)
if document.manifestless:
return MetadataEnrichmentOutcome(pkg.name, "manifestless")
data = load_yaml_str(document.content or "")
if not isinstance(data, dict):
return MetadataEnrichmentOutcome(pkg.name, "empty")
result: dict[str, str] = {}
Expand Down Expand Up @@ -1349,7 +1401,8 @@ def _prefetch_metadata(

This is the single authority for deciding whether metadata-dependent
output is certifiable. A reachable manifest without description or
version is ``empty``; an unavailable remote source is ``failed`` or
version is ``empty``; a verified skill without a manifest is
``manifestless``; an unavailable remote source is ``failed`` or
intentionally ``offline`` and cannot certify a clean regeneration.
"""
if self._metadata_enrichment is not None:
Expand Down
Loading