Skip to content

Commit 1815123

Browse files
nadav-yclaude
andcommitted
test(plugin_exporter): update archive assertions from .tar.gz to .zip
Three test files were still expecting the old default archive format (.tar.gz). Updated to assert .zip and use zipfile.ZipFile / is_zipfile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent afdd0a9 commit 1815123

9 files changed

Lines changed: 68 additions & 12 deletions

File tree

packages/apm-guide/.apm/skills/apm-usage/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ When `apm install` has already deployed instructions to `.claude/rules/`, `apm c
8080

8181
| Command | Purpose | Key flags |
8282
|---------|---------|-----------|
83-
| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. 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. 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: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. 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`, `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--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` (release gate: regenerate-and-diff against the committed `marketplace.json`; 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. |
83+
| `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). Default output is a Claude Code plugin directory. 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. 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: when present, they run after the build and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast. 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 an archive instead of a directory), `--archive-format [zip\|tar.gz]` (default `zip`; only active with `--archive`), `--dry-run`, `--format [plugin\|apm]` (default `plugin`), `--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` (release gate: regenerate-and-diff against the committed `marketplace.json`; 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. |
8484
| `apm unpack BUNDLE` | **[Deprecated]** Extract a bundle. Use `apm install <bundle-path>` instead -- it deploys directly with integrity verification and target resolution. | `-o PATH`, `--skip-verify`, `--force`, `--dry-run` |
8585

8686
`apm install <BUNDLE-PATH>` -- when the positional argument resolves to a directory containing `plugin.json` at its root, or to a `.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. Target resolution follows the same precedence as registry installs (`--target` > `apm.yml` > directory detection); the bundle itself carries no target binding. Compile-only targets (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. 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.

src/apm_cli/bundle/packer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ def pack_bundle(
274274

275275
# 10. Archive if requested
276276
if archive:
277+
if archive_format not in ("zip", "tar.gz"):
278+
raise ValueError(
279+
f"Unknown archive_format: {archive_format!r}. Must be 'zip' or 'tar.gz'."
280+
)
277281
if archive_format == "tar.gz":
278282
archive_path = output_dir / f"{pkg_name}-{pkg_version}.tar.gz"
279283
with tarfile.open(archive_path, "w:gz") as tf:

src/apm_cli/bundle/plugin_exporter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,10 @@ def export_plugin_bundle(
648648

649649
# 15. Archive if requested
650650
if archive:
651+
if archive_format not in ("zip", "tar.gz"):
652+
raise ValueError(
653+
f"Unknown archive_format: {archive_format!r}. Must be 'zip' or 'tar.gz'."
654+
)
651655
if archive_format == "tar.gz":
652656
archive_path = output_dir / f"{bundle_dir.name}.tar.gz"
653657
ensure_path_within(archive_path, output_dir)

src/apm_cli/commands/pack.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
2727
Reads apm.yml to decide what to produce:
2828
29-
dependencies: block -> bundle (directory or .zip)
29+
dependencies: block -> bundle (directory or archive; see --archive and --archive-format)
3030
marketplace: block -> selected marketplace artifacts
3131
target: / targets: -> ecosystem-specific plugin.json (claude/copilot)
3232
both blocks present -> bundle plus selected marketplace artifacts

tests/unit/commands/test_pack_cli_surface.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,30 @@ def test_format_invalid_choice_fails(self) -> None:
493493
result = CliRunner().invoke(pack_cmd, ["--format", "invalid"])
494494
assert result.exit_code != 0
495495

496+
def test_archive_format_zip_accepted(self, tmp_path: Path, monkeypatch) -> None:
497+
(tmp_path / "apm.yml").write_text(_APM_SIMPLE, encoding="utf-8")
498+
monkeypatch.chdir(tmp_path)
499+
result = CliRunner().invoke(pack_cmd, ["--dry-run", "--archive", "--archive-format", "zip"])
500+
assert "Invalid value for '--archive-format'" not in (result.output or "")
501+
assert result.exit_code in (0, 1)
502+
503+
def test_archive_format_tar_gz_accepted(self, tmp_path: Path, monkeypatch) -> None:
504+
(tmp_path / "apm.yml").write_text(_APM_SIMPLE, encoding="utf-8")
505+
monkeypatch.chdir(tmp_path)
506+
result = CliRunner().invoke(
507+
pack_cmd, ["--dry-run", "--archive", "--archive-format", "tar.gz"]
508+
)
509+
assert "Invalid value for '--archive-format'" not in (result.output or "")
510+
assert result.exit_code in (0, 1)
511+
512+
def test_archive_format_invalid_choice_fails(self) -> None:
513+
"""Click rejects unknown archive format values before the command runs."""
514+
result = CliRunner().invoke(pack_cmd, ["--archive", "--archive-format", "bz2"])
515+
assert result.exit_code != 0
516+
assert (
517+
"invalid" in (result.output or "").lower() or "error" in (result.output or "").lower()
518+
)
519+
496520
def test_deprecated_target_flag_emits_warning(self, tmp_path: Path, monkeypatch) -> None:
497521
(tmp_path / "apm.yml").write_text(_APM_SIMPLE, encoding="utf-8")
498522
monkeypatch.chdir(tmp_path)

tests/unit/test_packer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,30 @@ def test_pack_archive(self, tmp_path):
219219
names = zf.namelist()
220220
assert any("a.md" in n for n in names)
221221

222+
def test_pack_archive_tar_gz(self, tmp_path):
223+
deployed = [".github/agents/a.md"]
224+
project = _setup_project(tmp_path, deployed, target="vscode")
225+
out = tmp_path / "build"
226+
227+
result = pack_bundle(project, out, archive=True, archive_format="tar.gz")
228+
229+
assert result.bundle_path.name == "test-pkg-1.0.0.tar.gz"
230+
assert result.bundle_path.exists()
231+
assert not (out / "test-pkg-1.0.0").exists()
232+
import tarfile
233+
234+
with tarfile.open(result.bundle_path, "r:gz") as tf:
235+
names = tf.getnames()
236+
assert any("a.md" in n for n in names)
237+
238+
def test_pack_archive_invalid_format_raises(self, tmp_path):
239+
deployed = [".github/agents/a.md"]
240+
project = _setup_project(tmp_path, deployed, target="vscode")
241+
out = tmp_path / "build"
242+
243+
with pytest.raises(ValueError, match="Unknown archive_format"):
244+
pack_bundle(project, out, archive=True, archive_format="bz2")
245+
222246
def test_pack_custom_output_dir(self, tmp_path):
223247
deployed = [".github/agents/a.md"]
224248
project = _setup_project(tmp_path, deployed, target="vscode")

tests/unit/test_plugin_exporter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
import os
5-
import tarfile
5+
import zipfile
66
from pathlib import Path
77
from unittest.mock import patch
88

@@ -700,11 +700,11 @@ def test_archive(self, tmp_path):
700700

701701
result = export_plugin_bundle(project, out, archive=True)
702702

703-
assert result.bundle_path.name == "test-pkg-1.0.0.tar.gz"
703+
assert result.bundle_path.name == "test-pkg-1.0.0.zip"
704704
assert result.bundle_path.exists()
705705
assert not (out / "test-pkg-1.0.0").exists()
706-
with tarfile.open(result.bundle_path, "r:gz") as tar:
707-
names = tar.getnames()
706+
with zipfile.ZipFile(result.bundle_path, "r") as zf:
707+
names = zf.namelist()
708708
assert any("agent.md" in n for n in names)
709709

710710
def test_dependency_components_included(self, tmp_path):

tests/unit/test_plugin_exporter_compatibility.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from __future__ import annotations
1616

1717
import json
18-
import tarfile
18+
import zipfile
1919
from unittest.mock import MagicMock, patch
2020

2121
import pytest
@@ -462,8 +462,8 @@ def test_archive_creates_tarball(self, tmp_path):
462462
output_dir.mkdir()
463463

464464
result = export_plugin_bundle(project, output_dir, archive=True)
465-
assert result.bundle_path.suffix == ".gz"
466-
assert tarfile.is_tarfile(str(result.bundle_path))
465+
assert result.bundle_path.suffix == ".zip"
466+
assert zipfile.is_zipfile(str(result.bundle_path))
467467

468468
def test_collision_warning_emitted_no_logger(self, tmp_path):
469469
from apm_cli.bundle.plugin_exporter import export_plugin_bundle

tests/unit/test_plugin_exporter_phase3w5.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from __future__ import annotations
1616

1717
import json
18-
import tarfile
18+
import zipfile
1919
from unittest.mock import MagicMock, patch
2020

2121
import pytest
@@ -462,8 +462,8 @@ def test_archive_creates_tarball(self, tmp_path):
462462
output_dir.mkdir()
463463

464464
result = export_plugin_bundle(project, output_dir, archive=True)
465-
assert result.bundle_path.suffix == ".gz"
466-
assert tarfile.is_tarfile(str(result.bundle_path))
465+
assert result.bundle_path.suffix == ".zip"
466+
assert zipfile.is_zipfile(str(result.bundle_path))
467467

468468
def test_collision_warning_emitted_no_logger(self, tmp_path):
469469
from apm_cli.bundle.plugin_exporter import export_plugin_bundle

0 commit comments

Comments
 (0)