Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ scout-pipeline-result.png
.playwright-mcp/
server.pid
.docs-rewrite-plan/
build/apm-*/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222)
- Non-skill integrators (agent, instruction, prompt, command, hook script-copy) silently adopt byte-identical pre-existing files so a degraded `deployed_files=[]` lockfile no longer permanently blocks installs gated by `required-packages-deployed`. (#1313)
- `apm audit` drift check now returns skip-with-info (`passed=True`) when the install cache is cold, instead of failing the audit; bare `apm audit` surfaces the skip reason on stderr so CI pipelines that have not yet run `apm install` are not incorrectly red-marked. (#1289)

### Added

- `apm pack --marketplace=FORMATS` filters which marketplace formats are built in a single run; accepts comma-separated names and sentinels `all`/`none`. (#1317)
- `apm pack --marketplace-path FORMAT=PATH` overrides the output path for a specific marketplace format at invocation time. Env var overrides (`APM_MARKETPLACE_<FORMAT>_PATH`) are planned for v0.15. (#1317)
- `apm pack --json` emits a stable JSON contract to stdout (`{ok, dry_run, warnings, errors, marketplace: {outputs: [{format, path, ...}]}}`); all logs move to stderr so downstream tooling can `jq` the output safely. (#1317)
- `marketplace.outputs` in `apm.yml` now accepts a map form keyed by format name (`outputs: {claude: {}, codex: {path: ...}}`), replacing the deprecated list form; the list form still parses with a one-cycle deprecation warning. (#1317)
- `apm marketplace init` now scaffolds the explicit map-form `outputs: {claude: {}}` so the default state is observable in the manifest. (#1317)

### Changed

- `--marketplace-output PATH` is now hidden from `--help` and emits a stderr deprecation warning; it auto-translates to `--marketplace-path claude=PATH`. Removal tracked in #1318. (#1317)
- `extends: org` now correctly layers `dependencies.require` and `dependencies.deny` from the parent policy when the child omits the `dependencies:` block entirely; `None` signals "no opinion" (transparent) while `[]` signals explicit override. (#1290)
- CI self-check job now uses `setup-only: true` + `apm audit --ci --no-drift` so managed files are not overwritten by `apm install` before `content-integrity` runs; documented the audit-only CI pattern and the install-before-audit blind spot in the enterprise and CI/CD guides. (#1291)
- Pin `Path.home()` under unit tests via a session-scoped autouse conftest fixture, fixing 56 Windows runner failures on the new `windows-2025-vs2026` GitHub-hosted image where `USERPROFILE`/`HOMEDRIVE`+`HOMEPATH` are not seeded for pytest workers; also patch the `_check_and_notify_updates` import binding in the disabled-self-update test so it no longer races on the version-check cache. (#1270)
Expand Down
14 changes: 11 additions & 3 deletions docs/src/content/docs/producer/publish-to-a-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ marketplace:
name: acme-org
url: https://github.com/acme-org

outputs: [claude] # default; add codex for Codex repo output
outputs: # map form (recommended)
claude: {} # default; add codex for Codex repo output

claude:
output: .claude-plugin/marketplace.json
Expand Down Expand Up @@ -108,13 +109,19 @@ For the full field reference (every key on every entry, including
`pluginRoot`, `outputs`, `claude`, `codex`, and pass-through fields
like `tags`, `author`, `license`), see the reference below.

Marketplace output targets use a selector-list pattern:
Marketplace output targets use a map-form pattern:

```yaml
marketplace:
outputs: [claude, codex]
outputs:
claude: {}
codex:
path: .agents/plugins/marketplace.json
Comment on lines +118 to +119
```

The legacy list form (`outputs: [claude, codex]`) still parses with a
deprecation warning but new projects should use the map form above.

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
Expand Down Expand Up @@ -143,6 +150,7 @@ apm pack --dry-run # resolve and print; do not write
apm pack --offline # cached refs only
apm pack --include-prerelease # allow pre-release tags
apm pack -v # per-entry resolution detail
apm pack --marketplace=claude --json # JSON output for CI pipelines
```

Exit codes: `0` build succeeded, `1` build error (network, missing
Expand Down
26 changes: 19 additions & 7 deletions docs/src/content/docs/reference/cli/pack.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ 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 legacy compatibility: override only the Claude/Anthropic output path. Prefer `marketplace.claude.output` in `apm.yml`. |
| `-m`, `--marketplace FORMATS` | all configured | Comma-separated list of marketplace formats to build. Sentinels: `all` (every configured format), `none` (skip marketplace entirely). |
| `--marketplace-path FORMAT=PATH` | manifest default | Override the output path for a specific format. Repeatable. Example: `--marketplace-path codex=./dist/codex.json`. |
| `--json` | off | Emit machine-readable JSON to stdout. All logs move to stderr. Shape: `{ok, dry_run, warnings, errors, marketplace: {outputs: [...]}}`. |
| `--marketplace-output PATH` | _(hidden)_ | **Deprecated.** Translates to `--marketplace-path claude=PATH` with a stderr warning. Will be removed in v0.15 (see #1318). |
| `--legacy-skill-paths` | off | Bundle skills under per-client paths (e.g. `.cursor/skills/`) instead of the converged `.agents/skills/`. Compatibility flag. |
| `--target`, `-t VALUE` | auto-detect | **Deprecated.** Recorded as informational `pack.target` metadata only; ignored by `apm install`. Will be removed in a future release. |

Expand All @@ -54,6 +57,15 @@ apm pack --format apm -o ./dist # legacy APM bundle layout
```bash
apm pack
apm pack --offline --dry-run

# Build only Claude format, output as JSON for CI:
apm pack --marketplace=claude --json

# Override codex output path:
apm pack --marketplace-path codex=./dist/codex-marketplace.json

# Build all formats, preview paths:
apm pack --marketplace=all --json | jq -r '.marketplace.outputs[].path'
```

### Both artifacts in one run
Expand All @@ -67,11 +79,10 @@ apm pack --archive --offline

```yaml
marketplace:
outputs: [claude, codex]
claude:
output: ./build/claude-marketplace.json
codex:
output: ./build/codex-marketplace.json
outputs:
claude: {}
codex:
path: ./build/codex-marketplace.json
```

### Preview without writing
Expand Down Expand Up @@ -124,7 +135,8 @@ Configure marketplace artifact paths in `apm.yml`: `marketplace.claude.output` c
- **Empty bundle warning.** If no files match (e.g. nothing was installed yet), `apm pack` emits a warning and exits `0` with an empty bundle. Verbose mode prints a hint to run `apm install` first.
- **Share line.** On success, `apm pack` prints `Share with: apm install <bundle-path>` so the produced bundle is immediately copy-pasteable.
- **Marketplace fallback.** With no `marketplace:` block in `apm.yml`, a legacy `marketplace.yml` file is read with a deprecation warning. Both files present is a hard error.
- **Marketplace outputs.** `marketplace.outputs` defaults to `[claude]`. Add `codex` to also write `.agents/plugins/marketplace.json`; when selected, each package must define `category`.
- **Marketplace outputs.** Configure via `marketplace.outputs` map (keyed by format). Claude is included by default. The legacy list form (`outputs: [claude]`) still parses with a deprecation warning. Use `--marketplace=` to filter which formats are built in a given invocation.
- **JSON mode.** `--json` makes `apm pack` machine-friendly: stdout is a single JSON object, all human-readable logs move to stderr. Combine with `--marketplace=` for selective CI matrix builds.

## Exit codes

Expand Down
143 changes: 139 additions & 4 deletions src/apm_cli/commands/pack.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Click commands for ``apm pack`` and ``apm unpack``."""

import json as json_mod
import sys
from pathlib import Path

Expand All @@ -14,6 +15,7 @@
)
from ..core.command_logger import CommandLogger
from ..core.target_detection import TargetParamType
from ..utils.console import set_console_stderr

_PACK_HELP = """\
Pack distributable artifacts from your APM project.
Expand Down Expand Up @@ -52,6 +54,21 @@
"""


def _emit_json_error_or_raise(ctx, json_output: bool, code: str, message: str):
"""Emit a JSON error envelope to stdout or raise ClickException."""
if json_output:
from ..marketplace.builder import BuildReport

click.echo(
json_mod.dumps(
BuildReport.failure_to_json_dict(errors=[{"code": code, "message": message}])
)
)
ctx.exit(1)
else:
raise click.ClickException(message)


@click.command(name="pack", help=_PACK_HELP)
@click.option(
"--format",
Expand Down Expand Up @@ -104,11 +121,38 @@
"marketplace_output",
type=click.Path(),
default=None,
hidden=True,
help=("[Deprecated] Override Claude output path. Use --marketplace-path claude=PATH instead."),
)
@click.option(
"-m",
"--marketplace",
"marketplace_filter",
type=str,
default=None,
help=(
"Comma-separated marketplace outputs to build (e.g. 'claude,codex'). "
"Use 'all' for every configured output, 'none' to skip marketplace. "
"Default: build all configured outputs."
),
)
@click.option(
"--marketplace-path",
"marketplace_path_overrides",
Comment on lines +130 to +141
type=str,
multiple=True,
help=(
"Marketplace legacy compatibility: override only the Claude/Anthropic "
"output path. Prefer marketplace.claude.output in apm.yml."
"Override output path for a format: FORMAT=PATH (repeatable). "
"Example: --marketplace-path claude=dist/marketplace.json"
),
)
@click.option(
"--json",
"json_output",
is_flag=True,
default=False,
help="Emit machine-readable JSON to stdout; logs go to stderr.",
)
@click.option(
"--legacy-skill-paths",
"legacy_skill_paths",
Expand All @@ -133,10 +177,79 @@ def pack_cmd(
offline,
include_prerelease,
marketplace_output,
marketplace_filter,
marketplace_path_overrides,
json_output,
legacy_skill_paths,
):
"""Pack APM artifacts: bundle and/or marketplace.json."""
from ..marketplace.output_profiles import known_output_names
from ..utils.path_security import validate_path_segments

# -- Stream discipline: under --json, route ALL output to stderr --
if json_output:
set_console_stderr(True)

logger = CommandLogger("pack", verbose=verbose, dry_run=dry_run)

# -- Deprecation: --marketplace-output → --marketplace-path claude=PATH --
if marketplace_output is not None:
translated = f"--marketplace-path claude={marketplace_output}"
click.echo(
f"Warning: --marketplace-output is deprecated and will be removed in v0.15. "
f"Use {translated} instead.",
err=True,
)
marketplace_path_overrides = (
*marketplace_path_overrides,
f"claude={marketplace_output}",
)
marketplace_output = None

# -- Parse --marketplace-path overrides --
path_overrides: dict[str, str] = {}
for override in marketplace_path_overrides:
if "=" not in override:
msg = f"--marketplace-path must be FORMAT=PATH, got: {override!r}"
_emit_json_error_or_raise(ctx, json_output, "cli_error", msg)
return
fmt_name, path_val = override.split("=", 1)
fmt_name = fmt_name.strip()
path_val = path_val.strip()
if fmt_name not in known_output_names():
msg = (
f"Unknown marketplace format '{fmt_name}' in --marketplace-path. "
f"Known formats: {', '.join(sorted(known_output_names()))}"
)
_emit_json_error_or_raise(ctx, json_output, "unknown_format", msg)
return
# Security: validate path to prevent traversal attacks
try:
validate_path_segments(path_val, context="--marketplace-path", allow_current_dir=True)
except Exception as exc:
_emit_json_error_or_raise(ctx, json_output, "path_error", str(exc))
return
path_overrides[fmt_name] = path_val

# -- Parse --marketplace filter --
marketplace_formats: tuple[str, ...] | None = None
if marketplace_filter is not None:
if marketplace_filter.strip().lower() == "none":
marketplace_formats = ()
elif marketplace_filter.strip().lower() == "all":
marketplace_formats = None # all configured
else:
requested = [f.strip() for f in marketplace_filter.split(",") if f.strip()]
known = known_output_names()
for r in requested:
if r not in known:
msg = (
f"Unknown marketplace format '{r}' in --marketplace. "
f"Known formats: {', '.join(sorted(known))}"
)
_emit_json_error_or_raise(ctx, json_output, "unknown_format", msg)
return
marketplace_formats = tuple(requested)
project_root = Path(".").resolve()
# Issue #1207 D1: when --target is not given, detect the project's
# actual target so the embedded ``pack.target`` reflects what was
Expand Down Expand Up @@ -169,15 +282,37 @@ def pack_cmd(
bundle_force=force,
marketplace_offline=offline,
marketplace_include_prerelease=include_prerelease,
marketplace_output=Path(marketplace_output) if marketplace_output else None,
marketplace_output=None,
marketplace_formats=marketplace_formats,
marketplace_path_overrides=path_overrides if path_overrides else None,
dry_run=dry_run,
verbose=verbose,
)

try:
result = BuildOrchestrator().run(options, logger=logger)
except BuildError as exc:
raise click.ClickException(str(exc)) # noqa: B904
_emit_json_error_or_raise(ctx, json_output, "build_error", str(exc))
return

# -- JSON output mode: consistent envelope --
if json_output:
envelope = {
"ok": True,
"dry_run": dry_run,
"warnings": [],
"errors": [],
"marketplace": {"outputs": []},
"bundle": None,
}
for sub in result.producer_results:
if sub.kind is OutputKind.MARKETPLACE and sub.payload is not None:
payload = sub.payload.to_json_dict()
envelope["warnings"] = payload.get("warnings", [])
envelope["marketplace"] = payload.get("marketplace", {"outputs": []})
break
click.echo(json_mod.dumps(envelope, indent=2))
return

for sub in result.producer_results:
if sub.kind is OutputKind.BUNDLE:
Expand Down
21 changes: 19 additions & 2 deletions src/apm_cli/core/build_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class BuildOptions:
marketplace_offline: bool = False
marketplace_include_prerelease: bool = False
marketplace_output: Path | None = None
marketplace_formats: tuple[str, ...] | None = None
marketplace_path_overrides: dict[str, str] | None = None
# Common options
dry_run: bool = False
verbose: bool = False
Expand Down Expand Up @@ -182,7 +184,13 @@ def _warn(msg: str) -> None:
resolve_result = None
output_reports = []
outputs: list[Path] = []
for output_name in config.outputs:

# Apply --marketplace filter: skip outputs not in the requested set
active_outputs = list(config.outputs)
if options.marketplace_formats is not None:
active_outputs = [o for o in active_outputs if o in options.marketplace_formats]

for output_name in active_outputs:
profile = MARKETPLACE_OUTPUTS.get(output_name)
if profile is None:
valid_targets = ", ".join(sorted(MARKETPLACE_OUTPUTS))
Expand All @@ -198,7 +206,16 @@ def _warn(msg: str) -> None:
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:

# Apply --marketplace-path override
if (
options.marketplace_path_overrides
and output_name in options.marketplace_path_overrides
):
output_path = project_root / options.marketplace_path_overrides[output_name]
elif (
profile.supports_cli_output_override and options.marketplace_output is not None
):
output_path = options.marketplace_output

output_report = builder.write_output(
Expand Down
Loading
Loading