You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Updated after devx-ux review. Original framing (just "ship the lockfile, extend unpack mapping") was too narrow. The cleaner unification: apm unpack shouldn't exist as a separate verb. Plugin bundles are pre-resolved packages; apm install already knows how to integrate those into a project. This issue now proposes that direction. Original problem statement preserved below for context.
TL;DR
Supply-chain: plugin bundles ship no apm.lock.yaml, so they have no embedded SBOM / integrity manifest / provenance. Add it.
Mental model: a plugin bundle IS a pre-resolved package. apm install already knows how to route a package's flat skills//agents//commands//instructions/ layout to the correct target tree. Make apm install accept a local plugin bundle (file or directory). This is what pip install ./foo.whl, cargo install --path ., and npm install ./pkg.tgz already do.
Deprecate apm unpack (and the apm bundle format) over two release cycles. Single canonical bundle format = plugin. Single canonical entry point = install.
Round-trip restored as a free side effect.
Background (verified empirically against apm HEAD 0.11.0, commit 2b9501ab)
apm pack default flipped from apm to plugin format in 0.11.
plugin bundle: <root>/plugin.json + flat skills/<slug>/SKILL.md, agents/<name>.md, commands/<name>.md, instructions/<file>. No apm.lock.yaml -- explicitly excluded at src/apm_cli/bundle/plugin_exporter.py:140-155.
apm unpack <plugin.tar.gz> exits 1 with apm.lock.yaml not found. Default pack -> unpack round-trip is broken in 0.11+ unless the user passes --format apm.
Why "ship the lockfile" alone isn't the right framing
LockedDependency (src/apm_cli/deps/lockfile.py:20-50) records repo_url, resolved_commit, resolved_ref, version, deployed_files, deployed_file_hashes, content_hash, is_insecure, discovered_via. It is already an SBOM + integrity manifest. Excluding it from plugin bundles means:
No tamper evidence -- a plugin bundle modified post-pack is indistinguishable from the legitimate one.
Not reproducible -- third parties cannot re-resolve from sources and verify they get the same artifact.
No machine-readable dep graph for scanners -- CVE/license/policy tooling cannot operate on the artifact.
Inconsistent with project posture -- APM enforces lockfile-frozen installs, hash verification, and content-security scanning everywhere else.
So the lockfile must be in plugin bundles for supply-chain reasons, full stop. (Precedent: pip RECORD, cargo shipping Cargo.lock in source crates, OCI manifests with layer digests, SLSA provenance, CycloneDX/SPDX SBOMs.)
But shipping the lockfile is not the way to fix unpack, because that path keeps two parallel verbs (install + unpack), two parallel bundle formats (apm + plugin), and two parallel routing-mapping codepaths. The integration pipeline that apm install already runs is exactly the logic unpack should be calling.
Proposed direction: unify on apm install
A plugin bundle is a pre-resolved package with an integration-ready flat layout. The existing apm install pipeline already:
Routes skills/, agents/, commands/, instructions/ to the correct target tree (.github/, .claude/, .gemini/, .cursor/, ...).
Applies the canonical naming convention.
Honors --target <t1,t2,...> for multi-target deploys.
Detects collisions via BaseIntegrator.check_collision() (skips locally-modified files unless --force).
Supports --dry-run.
All of this should "just work" if apm install accepts a local bundle:
apm install ./bundle.tar.gz # detect plugin.json -> treat as pre-resolved
apm install ./bundle-dir/ # same, directory form
apm install owner/repo # remote, full resolution (today's behavior)
This matches what pip/cargo/npm already do, eliminating an APM-specific verb users have to learn.
Concrete behavior
Detection: apm install <local-path> checks for plugin.json at root. If present, treat the path as a pre-resolved package and skip dependency resolution.
Lockfile: read embedded apm.lock.yaml (after change 1 below). Use it for provenance verification (hash check on each file, integrity check on content_hash) -- NOT for routing. This is a semantic inversion of the lockfile's role inside a bundle (today: routing manifest; tomorrow: SBOM/integrity).
Routing: hand off to the same integrator pipeline apm install owner/repo already runs. --target resolves identically (auto-detect from apm.yml or workspace; explicit --target a,b for multi-target).
Naming: derive the package identifier for the -<package> suffix from plugin.json:id. Fallback: bundle dirname. Error if neither resolves. Add --as <alias> to override (matching the existing owner/repo@alias pattern).
[!] 3 files skipped (locally modified). Use --force to overwrite.
.github/instructions/coding-style.md
.claude/agents/reviewer.md
.cursor/rules/linting.md
Offline guarantee: apm install <local-path> must do zero network I/O when the source is a local file. Verify the integrator codepath has no implicit network calls (link resolution, marketplace lookups). The current apm unpack is fully offline; that property must be preserved when migrating bundles to install.
Coordinated changes required
Change 1 -- ship apm.lock.yaml in plugin bundles. Drop it from the exclusion list in _collect_root_plugin_components and per-dependency walker (plugin_exporter.py:140-155). Place at <bundle-root>/apm.lock.yaml. Claude Code consumers ignore it. Bundle gains a verifiable SBOM at no behavioral cost.
Change 2 -- apm install accepts local bundle paths. Detect plugin.json at root, route through the existing integrator pipeline, verify against embedded lockfile.
Change 3 -- embed pack-time target metadata for round-trip fidelity. A bundle packed with --target copilot,claude and re-installed in a project that auto-detects only copilot would silently drop the claude artifacts. Embed the original --target set in plugin.json (or apm.lock.yaml) and have install warn:
[!] Bundle was packed for [copilot, claude]. Current resolved target is [copilot].
Use --target copilot,claude to restore the full layout.
Change 4 -- deprecate apm unpack and the apm bundle format. Suggested timeline:
Release
apm install <bundle>
apm unpack <bundle>
--format apm
v0.12
works on plugin bundles via integrator
thin alias -> install; deprecation notice
works; no notice
v0.13
unchanged
deprecation notice
deprecation notice
v0.14 or v1.0
unchanged
removed
removed
Two release cycles is reasonable for a pre-1.0 tool.
Footguns / blind spots called out
Lockfile role inversion (today: routing; tomorrow: provenance) needs explicit documentation so reviewers and downstream tools don't assume routing semantics.
Air-gapped installs must remain network-free; integrator codepath needs an audit before unifying.
Round-trip fidelity requires embedding target-set metadata in the bundle (Change 3).
Behavior migration for users of today's apm unpack: file layout was bundle-baked; with the new path it becomes target-resolved. Migration notes should call this out explicitly.
Downstream impact
microsoft/apm-action PR Refactor prompt integration and add agent integration #31: defensive bundle-format: apm default and plugin-rejection error stay useful as defense-in-depth until v0.13. After v0.14, action can drop both and let bundle-format follow upstream default.
gh-aw workflows that round-trip via apm pack + apm unpack are silently broken on 0.11+ today; fixed once Change 2 lands.
CI integrity gates: workflows can verify bundle provenance from the embedded lockfile. Capability does not exist today on plugin bundles.
Related downstream issue: microsoft/apm-action#24 (setup-only) -- separate but driven by the same hardening pass.
Original framing (preserved for history)
Original draft proposed extending the lockfile schema (deployed_files: list[{bundle, deployed}]) so apm unpack could route plugin bundles via lockfile mapping. devx-ux review pushed back: that keeps two parallel verbs and two parallel formats. The unified-on-install direction above is strictly cleaner, matches package-manager precedent (pip/cargo/npm), and the schema extension is unnecessary because the integration pipeline already knows how to route flat plugin layouts.
TL;DR
apm.lock.yaml, so they have no embedded SBOM / integrity manifest / provenance. Add it.apm installalready knows how to route a package's flatskills//agents//commands//instructions/layout to the correct target tree. Makeapm installaccept a local plugin bundle (file or directory). This is whatpip install ./foo.whl,cargo install --path ., andnpm install ./pkg.tgzalready do.apm unpack(and theapmbundle format) over two release cycles. Single canonical bundle format = plugin. Single canonical entry point =install.Background (verified empirically against
apmHEAD0.11.0, commit2b9501ab)apm packdefault flipped fromapmtopluginformat in 0.11.pluginbundle:<root>/plugin.json+ flatskills/<slug>/SKILL.md,agents/<name>.md,commands/<name>.md,instructions/<file>. Noapm.lock.yaml-- explicitly excluded atsrc/apm_cli/bundle/plugin_exporter.py:140-155.apmbundle:<root>/.github/<kind>/<slug>/<file>(target-deployed paths) +apm.lock.yamlwhosedeployed_filesdrivesapm unpack.apm unpack <plugin.tar.gz>exits 1 withapm.lock.yaml not found. Defaultpack->unpackround-trip is broken in 0.11+ unless the user passes--format apm.microsoft/apm-actionPR Refactor prompt integration and add agent integration #31 currently defends downstream by defaultingbundle-format: apmand rejecting plugin tarballs at restore.Why "ship the lockfile" alone isn't the right framing
LockedDependency(src/apm_cli/deps/lockfile.py:20-50) recordsrepo_url,resolved_commit,resolved_ref,version,deployed_files,deployed_file_hashes,content_hash,is_insecure,discovered_via. It is already an SBOM + integrity manifest. Excluding it from plugin bundles means:So the lockfile must be in plugin bundles for supply-chain reasons, full stop. (Precedent: pip
RECORD, cargo shippingCargo.lockin source crates, OCI manifests with layer digests, SLSA provenance, CycloneDX/SPDX SBOMs.)But shipping the lockfile is not the way to fix unpack, because that path keeps two parallel verbs (
install+unpack), two parallel bundle formats (apm+plugin), and two parallel routing-mapping codepaths. The integration pipeline thatapm installalready runs is exactly the logic unpack should be calling.Proposed direction: unify on
apm installA plugin bundle is a pre-resolved package with an integration-ready flat layout. The existing
apm installpipeline already:skills/,agents/,commands/,instructions/to the correct target tree (.github/,.claude/,.gemini/,.cursor/, ...).--target <t1,t2,...>for multi-target deploys.BaseIntegrator.check_collision()(skips locally-modified files unless--force).--dry-run.All of this should "just work" if
apm installaccepts a local bundle:This matches what pip/cargo/npm already do, eliminating an APM-specific verb users have to learn.
Concrete behavior
apm install <local-path>checks forplugin.jsonat root. If present, treat the path as a pre-resolved package and skip dependency resolution.apm.lock.yaml(after change 1 below). Use it for provenance verification (hash check on each file, integrity check oncontent_hash) -- NOT for routing. This is a semantic inversion of the lockfile's role inside a bundle (today: routing manifest; tomorrow: SBOM/integrity).apm install owner/repoalready runs.--targetresolves identically (auto-detect fromapm.ymlor workspace; explicit--target a,bfor multi-target).-<package>suffix fromplugin.json:id. Fallback: bundle dirname. Error if neither resolves. Add--as <alias>to override (matching the existingowner/repo@aliaspattern).install's collision behavior (skip locally-modified files unless--force). Print skip count + remediation:apm install <local-path>must do zero network I/O when the source is a local file. Verify the integrator codepath has no implicit network calls (link resolution, marketplace lookups). The currentapm unpackis fully offline; that property must be preserved when migrating bundles toinstall.Coordinated changes required
Change 1 -- ship
apm.lock.yamlin plugin bundles. Drop it from the exclusion list in_collect_root_plugin_componentsand per-dependency walker (plugin_exporter.py:140-155). Place at<bundle-root>/apm.lock.yaml. Claude Code consumers ignore it. Bundle gains a verifiable SBOM at no behavioral cost.Change 2 --
apm installaccepts local bundle paths. Detectplugin.jsonat root, route through the existing integrator pipeline, verify against embedded lockfile.Change 3 -- embed pack-time target metadata for round-trip fidelity. A bundle packed with
--target copilot,claudeand re-installed in a project that auto-detects onlycopilotwould silently drop theclaudeartifacts. Embed the original--targetset inplugin.json(orapm.lock.yaml) and haveinstallwarn:Change 4 -- deprecate
apm unpackand theapmbundle format. Suggested timeline:apm install <bundle>apm unpack <bundle>--format apminstall; deprecation noticeTwo release cycles is reasonable for a pre-1.0 tool.
Footguns / blind spots called out
apm unpack: file layout was bundle-baked; with the new path it becomes target-resolved. Migration notes should call this out explicitly.Downstream impact
microsoft/apm-actionPR Refactor prompt integration and add agent integration #31: defensivebundle-format: apmdefault and plugin-rejection error stay useful as defense-in-depth until v0.13. After v0.14, action can drop both and letbundle-formatfollow upstream default.gh-awworkflows that round-trip viaapm pack+apm unpackare silently broken on 0.11+ today; fixed once Change 2 lands.Suggested labels
type/feature,theme/security,theme/portability,area/package-authoring,status/needs-triageReferences
src/apm_cli/bundle/plugin_exporter.py:140-155LockedDependencyschema:src/apm_cli/deps/lockfile.py:20-50microsoft/apm-action#31microsoft/apm-action#24(setup-only) -- separate but driven by the same hardening pass.Original framing (preserved for history)
Original draft proposed extending the lockfile schema (
deployed_files: list[{bundle, deployed}]) soapm unpackcould route plugin bundles via lockfile mapping. devx-ux review pushed back: that keeps two parallel verbs and two parallel formats. The unified-on-installdirection above is strictly cleaner, matches package-manager precedent (pip/cargo/npm), and the schema extension is unnecessary because the integration pipeline already knows how to route flat plugin layouts.