feat(factory): add native Droid plugin packaging (#120)

* feat(factory): add native Droid plugin packaging

* test(factory): guard documented install contract

* fix(factory): enforce install command order
This commit is contained in:
Cathryn Lavery
2026-08-19 17:46:46 -07:00
committed by GitHub
parent bf49fb408a
commit 8154ec97bf
12 changed files with 420 additions and 31 deletions
+108 -5
View File
@@ -11,7 +11,7 @@ import tempfile
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType
from typing import Iterator
from typing import Iterator, Optional
ROOT = Path(__file__).resolve().parent.parent
VERIFY_SCRIPT = ROOT / "scripts/verify-plugin-package.py"
@@ -50,9 +50,24 @@ def manifest(version: str, codex: bool = False) -> dict:
return payload
def seed_package(root: Path, version: str = "1.2.3") -> None:
def seed_factory(root: Path, version: str) -> None:
write_json(root / ".factory-plugin/plugin.json", manifest(version))
write_json(
root / ".factory-plugin/marketplace.json",
{
"name": PLUGIN_NAME,
"plugins": [{"name": PLUGIN_NAME, "source": "./"}],
},
)
def seed_package(
root: Path, version: str = "1.2.3", include_factory: bool = True
) -> None:
write_json(root / ".claude-plugin/plugin.json", manifest(version))
write_json(root / ".codex-plugin/plugin.json", manifest(version, codex=True))
if include_factory:
seed_factory(root, version)
write_json(
root / ".claude-plugin/marketplace.json",
{
@@ -80,13 +95,16 @@ def seed_package(root: Path, version: str = "1.2.3") -> None:
skill = root / "skills" / PLUGIN_NAME / "SKILL.md"
skill.parent.mkdir(parents=True, exist_ok=True)
skill.write_text(f"---\nname: {PLUGIN_NAME}\n---\n", encoding="utf-8")
command = root / "commands" / "export-diagram.md"
command.parent.mkdir(parents=True, exist_ok=True)
command.write_text("Export the diagram.\n", encoding="utf-8")
@contextmanager
def package_repo() -> Iterator[Path]:
def package_repo(include_factory: bool = True) -> Iterator[Path]:
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
seed_package(root)
seed_package(root, include_factory=include_factory)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "Package Test"], cwd=root, check=True)
subprocess.run(
@@ -99,16 +117,31 @@ def package_repo() -> Iterator[Path]:
yield root
def set_versions(root: Path, claude: str, codex: str) -> None:
def set_versions(
root: Path, claude: str, codex: str, factory: Optional[str] = None
) -> None:
if factory is None:
factory = codex
for relative, version in (
(Path(".claude-plugin/plugin.json"), claude),
(Path(".codex-plugin/plugin.json"), codex),
(Path(".factory-plugin/plugin.json"), factory),
):
payload = json.loads((root / relative).read_text(encoding="utf-8"))
payload["version"] = version
write_json(root / relative, payload)
def set_existing_versions(root: Path, version: str) -> None:
for relative in VERIFY.MANIFEST_PATHS.values():
path = root / relative
if not path.is_file():
continue
payload = json.loads(path.read_text(encoding="utf-8"))
payload["version"] = version
write_json(path, payload)
def expect_failure(label: str, errors: list[str], needle: str) -> None:
if not any(needle in error for error in errors):
raise AssertionError(f"{label}: expected {needle!r}, got {errors}")
@@ -138,6 +171,14 @@ def test_verifier() -> None:
"versions must match",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4", "1.2.5")
expect_failure(
"Factory version drift",
VERIFY.verify_package(root, "HEAD"),
"versions must match",
)
with package_repo() as root:
set_versions(root, "1.2", "1.2")
expect_failure(
@@ -158,6 +199,68 @@ def test_verifier() -> None:
"target does not exist",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
marketplace_path = root / ".factory-plugin/marketplace.json"
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
marketplace["plugins"][0]["source"] = {
"source": "local",
"path": "./",
}
write_json(marketplace_path, marketplace)
expect_failure(
"non-native Factory marketplace source",
VERIFY.verify_package(root, "HEAD"),
"must be a non-empty local path string",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
(root / "commands/export-diagram.md").unlink()
expect_failure(
"missing shared command surface",
VERIFY.verify_package(root, "HEAD"),
"packaged commands are missing",
)
with package_repo(include_factory=False) as root:
set_existing_versions(root, "1.2.4")
seed_factory(root, "1.2.4")
errors = VERIFY.verify_package(root, "HEAD")
if errors:
raise AssertionError(f"valid Factory bootstrap failed: {errors}")
print("OK: synchronized Factory bootstrap accepted")
with package_repo(include_factory=False) as root:
seed_factory(root, "1.2.3")
expect_failure(
"Factory bootstrap without package bump",
VERIFY.verify_package(root, "HEAD"),
"must increase",
)
with package_repo(include_factory=False) as root:
set_existing_versions(root, "1.2.4")
seed_factory(root, "1.2.4")
factory_path = root / ".factory-plugin/plugin.json"
factory = json.loads(factory_path.read_text(encoding="utf-8"))
factory["description"] = "Drifted Factory description."
write_json(factory_path, factory)
expect_failure(
"Factory bootstrap with manifest drift",
VERIFY.verify_package(root, "HEAD"),
"must match Claude",
)
with package_repo() as root:
set_versions(root, "1.2.4", "1.2.4")
(root / ".factory-plugin/plugin.json").unlink()
expect_failure(
"Factory manifest deletion",
VERIFY.verify_package(root, "HEAD"),
"could not read",
)
def test_bumper() -> None:
cases = (("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0"))