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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.13", "version": "2.5.14",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.13", "version": "2.5.14",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+9
View File
@@ -0,0 +1,9 @@
{
"name": "diagram-design",
"plugins": [
{
"name": "diagram-design",
"source": "./"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.14",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
},
"homepage": "https://github.com/cathrynlavery/diagram-design",
"repository": "https://github.com/cathrynlavery/diagram-design",
"license": "MIT",
"keywords": [
"diagrams",
"svg",
"architecture",
"flowchart",
"visualization",
"editorial",
"drawio",
"mermaid",
"import",
"animation",
"semantic-patterns"
]
}
+19 -9
View File
@@ -14,7 +14,7 @@
*New in 2.5.10: ten more layout grammars — Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema.* *New in 2.5.10: ten more layout grammars — Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema.*
38 editorial diagram types for Claude Code, Codex, and Pi. Self-contained HTML + SVG. No shadows. No Mermaid slop. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level. 38 editorial diagram types for Claude Code, Codex, Factory Droid, and Pi. Self-contained HTML + SVG. No shadows. No Mermaid slop. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level.
No Figma. No generic rounded boxes. No 30-minute color-picking sessions. No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
@@ -128,6 +128,15 @@ codex plugin add diagram-design@diagram-design
Codex refreshes configured Git marketplaces at startup. To fetch immediately, run `codex plugin marketplace upgrade diagram-design` and start a new session. Codex refreshes configured Git marketplaces at startup. To fetch immediately, run `codex plugin marketplace upgrade diagram-design` and start a new session.
**Factory Droid:**
```bash
droid plugin marketplace add https://github.com/cathrynlavery/diagram-design
droid plugin install diagram-design@diagram-design --scope user
```
Droid tracks Git plugins by commit rather than the manifest's display version. To fetch a merged update, run `droid plugin marketplace update diagram-design`, then `droid plugin update diagram-design@diagram-design --scope user`, and start a new session.
**Claude Cowork (organization marketplace):** Organization GitHub marketplaces currently require a private or internal repository, so first mirror this public repository into one owned by your organization. In **Organization settings → Plugins**, choose **Add plugin → GitHub**, connect that mirror, and enable **Sync automatically** from the marketplace menu. Automatic sync runs when a pull request containing a plugin version bump is merged to the mirror's default branch; direct pushes do not trigger the webhook. Install Diagram Design from the resulting organization marketplace. **Claude Cowork (organization marketplace):** Organization GitHub marketplaces currently require a private or internal repository, so first mirror this public repository into one owned by your organization. In **Organization settings → Plugins**, choose **Add plugin → GitHub**, connect that mirror, and enable **Sync automatically** from the marketplace menu. Automatic sync runs when a pull request containing a plugin version bump is merged to the mirror's default branch; direct pushes do not trigger the webhook. Install Diagram Design from the resulting organization marketplace.
**Pi:** **Pi:**
@@ -154,7 +163,7 @@ pi install ~/code/diagram-design
ln -s ~/code/diagram-design/skills/diagram-design ~/.claude/skills/diagram-design ln -s ~/code/diagram-design/skills/diagram-design ~/.claude/skills/diagram-design
``` ```
The shared skill lives at `skills/diagram-design/`. Pi discovers it through the repo's standard `skills/` package directory; Claude Code, Codex, and other Agent Skills-compatible tools use the same files. The shared skill lives at `skills/diagram-design/`. Pi discovers it through the repo's standard `skills/` package directory; Claude Code, Codex, Factory Droid, and other Agent Skills-compatible tools use the same files.
--- ---
@@ -218,7 +227,7 @@ See [`skills/diagram-design/references/onboarding.md`](skills/diagram-design/ref
Onboard a brand once, save the result as a named profile, then add a `.diagram-design` marker containing `profile: <slug>` to each client project. Marker projects read `~/.diagram-design/profiles/<slug>.md` directly, so parallel workspaces can use different brands without overwriting a shared installed `style-guide.md`. Onboard a brand once, save the result as a named profile, then add a `.diagram-design` marker containing `profile: <slug>` to each client project. Marker projects read `~/.diagram-design/profiles/<slug>.md` directly, so parallel workspaces can use different brands without overwriting a shared installed `style-guide.md`.
The profile library is shared across Claude Code, Codex, and Pi. Use `/diagram-design:profile` in Claude Code, `/profile` in Pi, or ask in natural language in any host. See [`profiles.md`](skills/diagram-design/references/profiles.md) for the storage, marker, and recovery contract. The profile library is shared across Claude Code, Codex, Factory Droid, and Pi. Use `/diagram-design:profile` in Claude Code, `/profile` in Factory Droid or Pi, or ask in natural language in any host. See [`profiles.md`](skills/diagram-design/references/profiles.md) for the storage, marker, and recovery contract.
--- ---
@@ -229,7 +238,7 @@ The profile library is shared across Claude Code, Codex, and Pi. Use `/diagram-d
open skills/diagram-design/assets/index.html # macOS open skills/diagram-design/assets/index.html # macOS
xdg-open skills/diagram-design/assets/index.html # Linux xdg-open skills/diagram-design/assets/index.html # Linux
# In Claude Code, Codex, or Pi, ask: # In Claude Code, Codex, Factory Droid, or Pi, ask:
# "Make me an architecture diagram of my app: frontend, backend, database, Redis cache." # "Make me an architecture diagram of my app: frontend, backend, database, Redis cache."
# "I need a quadrant showing Q2 projects by impact vs effort." # "I need a quadrant showing Q2 projects by impact vs effort."
# "Give me a sequence of a bearer call with token refresh on 401." # "Give me a sequence of a bearer call with token refresh on 401."
@@ -343,11 +352,12 @@ diagram-design/
├── .agents/plugins/marketplace.json — Codex marketplace catalog ├── .agents/plugins/marketplace.json — Codex marketplace catalog
├── .claude-plugin/ — Claude marketplace + plugin manifest ├── .claude-plugin/ — Claude marketplace + plugin manifest
├── .codex-plugin/ — Codex plugin manifest ├── .codex-plugin/ — Codex plugin manifest
├── .factory-plugin/ — Factory Droid marketplace + plugin manifest
├── commands/ ├── commands/
│ ├── export-diagram.md — Claude Code export command │ ├── export-diagram.md — plugin export command
│ ├── import-drawio.md — Claude Code draw.io import command │ ├── import-drawio.md — plugin draw.io import command
│ ├── import-mermaid.md — Claude Code Mermaid import command │ ├── import-mermaid.md — plugin Mermaid import command
│ └── profile.md — Claude Code client-profile command │ └── profile.md — plugin client-profile command
├── prompts/ ├── prompts/
│ ├── export-diagram.md — Pi `/export-diagram` prompt template │ ├── export-diagram.md — Pi `/export-diagram` prompt template
│ ├── import-mermaid.md — Pi Mermaid import prompt template │ ├── import-mermaid.md — Pi Mermaid import prompt template
@@ -407,7 +417,7 @@ diagram-design/
│ ├── example-policy-trace-animated.html │ ├── example-policy-trace-animated.html
│ └── example-sequence-oauth*.html │ └── example-sequence-oauth*.html
├── scripts/ ├── scripts/
│ ├── bump-plugin-version.py — synchronized Claude/Codex version bump │ ├── bump-plugin-version.py — synchronized Claude/Codex/Factory version bump
│ ├── render-canonical-screenshots.py — deterministic 38-type PNG catalog renderer │ ├── render-canonical-screenshots.py — deterministic 38-type PNG catalog renderer
│ ├── verify-screenshot-freshness.py — source + screenshot digest gate │ ├── verify-screenshot-freshness.py — source + screenshot digest gate
│ ├── verify-plugin-package.py — version + marketplace package gate │ ├── verify-plugin-package.py — version + marketplace package gate
@@ -0,0 +1,17 @@
# ADR 0008 — Native host manifests share one plugin root
**Status:** accepted (v2.5.14)
## Context
Diagram Design serves Claude Code, Codex, Pi, and Factory Droid. Factory can translate the Claude plugin layout, but relying on that fallback leaves Droid installation undocumented and outside the package-version gate. Copying the skill or commands into host-specific directories would create multiple sources of truth.
## Decision
Claude, Codex, and Factory each receive the smallest native manifest and marketplace metadata their host needs. Every marketplace resolves to the repository root, where all three hosts reuse `skills/diagram-design/` and `commands/` without duplication. Pi continues to use the same root package surfaces.
The three native plugin manifests carry identical shared identity, description, version, author, repository, license, and keyword metadata. The package verifier rejects drift, deletion, unsafe marketplace paths, or a version that does not advance from the base ref. A newly tracked native manifest may be absent at the base ref only during bootstrap: its current metadata and version must match the established manifests, and those established manifests must advance.
## Consequences
Each native host has an explicit install path while diagram behavior remains single-sourced. Adding another host requires native metadata, package-gate coverage, documentation, and a synchronized version bump; it never justifies copying the skill or command surface. Git-based Factory installs are updated by marketplace commit, while the synchronized manifest version remains release metadata and a review gate.
+5 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Increment the synchronized Claude and Codex plugin manifest versions.""" """Increment the synchronized Claude, Codex, and Factory manifest versions."""
from __future__ import annotations from __future__ import annotations
@@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parent.parent
MANIFEST_PATHS = ( MANIFEST_PATHS = (
Path(".claude-plugin/plugin.json"), Path(".claude-plugin/plugin.json"),
Path(".codex-plugin/plugin.json"), Path(".codex-plugin/plugin.json"),
Path(".factory-plugin/plugin.json"),
) )
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
@@ -46,7 +47,7 @@ def bump(root: Path, part: str = "patch") -> str:
if any(version != raw_versions[0] for version in raw_versions[1:]): if any(version != raw_versions[0] for version in raw_versions[1:]):
rendered = ", ".join( rendered = ", ".join(
f"{relative}={version!r}" f"{relative}={version!r}"
for (relative, _), version in zip(manifests, raw_versions, strict=True) for (relative, _), version in zip(manifests, raw_versions)
) )
raise PackageVersionError(f"manifest versions are not synchronized: {rendered}") raise PackageVersionError(f"manifest versions are not synchronized: {rendered}")
@@ -74,7 +75,7 @@ def bump(root: Path, part: str = "patch") -> str:
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Increment both plugin manifests (patch by default)." description="Increment all plugin manifests (patch by default)."
) )
group = parser.add_mutually_exclusive_group() group = parser.add_mutually_exclusive_group()
group.add_argument("--major", action="store_true", help="increment the major version") group.add_argument("--major", action="store_true", help="increment the major version")
@@ -90,7 +91,7 @@ def main() -> int:
except PackageVersionError as exc: except PackageVersionError as exc:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return 1 return 1
print(f"Updated Claude and Codex plugin manifests to {version}") print(f"Updated Claude, Codex, and Factory plugin manifests to {version}")
return 0 return 0
+108 -5
View File
@@ -11,7 +11,7 @@ import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Iterator from typing import Iterator, Optional
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
VERIFY_SCRIPT = ROOT / "scripts/verify-plugin-package.py" VERIFY_SCRIPT = ROOT / "scripts/verify-plugin-package.py"
@@ -50,9 +50,24 @@ def manifest(version: str, codex: bool = False) -> dict:
return payload 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 / ".claude-plugin/plugin.json", manifest(version))
write_json(root / ".codex-plugin/plugin.json", manifest(version, codex=True)) write_json(root / ".codex-plugin/plugin.json", manifest(version, codex=True))
if include_factory:
seed_factory(root, version)
write_json( write_json(
root / ".claude-plugin/marketplace.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 = root / "skills" / PLUGIN_NAME / "SKILL.md"
skill.parent.mkdir(parents=True, exist_ok=True) skill.parent.mkdir(parents=True, exist_ok=True)
skill.write_text(f"---\nname: {PLUGIN_NAME}\n---\n", encoding="utf-8") 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 @contextmanager
def package_repo() -> Iterator[Path]: def package_repo(include_factory: bool = True) -> Iterator[Path]:
with tempfile.TemporaryDirectory() as scratch: with tempfile.TemporaryDirectory() as scratch:
root = Path(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", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "Package Test"], cwd=root, check=True) subprocess.run(["git", "config", "user.name", "Package Test"], cwd=root, check=True)
subprocess.run( subprocess.run(
@@ -99,16 +117,31 @@ def package_repo() -> Iterator[Path]:
yield root 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 ( for relative, version in (
(Path(".claude-plugin/plugin.json"), claude), (Path(".claude-plugin/plugin.json"), claude),
(Path(".codex-plugin/plugin.json"), codex), (Path(".codex-plugin/plugin.json"), codex),
(Path(".factory-plugin/plugin.json"), factory),
): ):
payload = json.loads((root / relative).read_text(encoding="utf-8")) payload = json.loads((root / relative).read_text(encoding="utf-8"))
payload["version"] = version payload["version"] = version
write_json(root / relative, payload) 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: def expect_failure(label: str, errors: list[str], needle: str) -> None:
if not any(needle in error for error in errors): if not any(needle in error for error in errors):
raise AssertionError(f"{label}: expected {needle!r}, got {errors}") raise AssertionError(f"{label}: expected {needle!r}, got {errors}")
@@ -138,6 +171,14 @@ def test_verifier() -> None:
"versions must match", "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: with package_repo() as root:
set_versions(root, "1.2", "1.2") set_versions(root, "1.2", "1.2")
expect_failure( expect_failure(
@@ -158,6 +199,68 @@ def test_verifier() -> None:
"target does not exist", "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: def test_bumper() -> None:
cases = (("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0")) cases = (("patch", "1.2.4"), ("minor", "1.3.0"), ("major", "2.0.0"))
+91 -1
View File
@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import json
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -90,8 +91,97 @@ def main() -> int:
if errors != [expected]: if errors != [expected]:
raise AssertionError(f"stale Pi prompt was not reported: {errors}") raise AssertionError(f"stale Pi prompt was not reported: {errors}")
factory_manifest = root / ".factory-plugin/plugin.json"
factory_marketplace = root / ".factory-plugin/marketplace.json"
factory_manifest.parent.mkdir(parents=True)
factory_manifest.write_text(
json.dumps(
{
"name": "diagram-design",
"repository": "https://github.com/example/diagram-design",
}
),
encoding="utf-8",
)
factory_marketplace.write_text(
json.dumps({"name": "diagram-design"}),
encoding="utf-8",
)
valid_readme = """# Diagram Design
```bash
droid plugin marketplace add https://github.com/example/diagram-design
droid plugin install diagram-design@diagram-design --scope user
```
```
diagram-design/
├── .factory-plugin/ — Factory Droid metadata
├── commands/
└── skills/
```
"""
readme = root / "README.md"
readme.write_text(valid_readme, encoding="utf-8")
errors = []
verify.check_factory_install_surface(errors, root)
if errors:
raise AssertionError(f"valid Factory install contract failed: {errors}")
readme.write_text(
valid_readme.replace(
"droid plugin marketplace add https://github.com/example/diagram-design\n"
"droid plugin install diagram-design@diagram-design --scope user",
"droid plugin install diagram-design@diagram-design --scope user\n"
"droid plugin marketplace add https://github.com/example/diagram-design",
),
encoding="utf-8",
)
errors = []
verify.check_factory_install_surface(errors, root)
expected = (
"README Factory install block must match native metadata: "
"`droid plugin marketplace add https://github.com/example/diagram-design` "
"then `droid plugin install diagram-design@diagram-design`"
)
if errors != [expected]:
raise AssertionError(
f"reversed Factory install commands were not reported: {errors}"
)
readme.write_text(
valid_readme.replace(
"diagram-design@diagram-design", "diagram-design@wrong-marketplace"
),
encoding="utf-8",
)
errors = []
verify.check_factory_install_surface(errors, root)
expected = (
"README Factory install block must match native metadata: "
"`droid plugin marketplace add https://github.com/example/diagram-design` "
"then `droid plugin install diagram-design@diagram-design`"
)
if errors != [expected]:
raise AssertionError(
f"drifted Factory plugin ID was not reported: {errors}"
)
readme.write_text(
valid_readme.replace("├── .factory-plugin/ — Factory Droid metadata\n", ""),
encoding="utf-8",
)
errors = []
verify.check_factory_install_surface(errors, root)
expected = (
"README architecture tree must list Factory's native .factory-plugin/ path"
)
if errors != [expected]:
raise AssertionError(f"missing Factory native path was not reported: {errors}")
print( print(
"PASS: docs sync checks reference links and Claude/Pi profile-surface parity" "PASS: docs sync checks references, profile surfaces, and Factory install contract"
) )
return 0 return 0
+53 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify that routing and browsing surfaces stay in sync with the skill. """Verify that routing and browsing surfaces stay in sync with the skill.
Five drift classes, each of which has shipped before: Seven drift classes, each of which has shipped before:
1. The SKILL.md frontmatter description is the only text an agent sees before 1. The SKILL.md frontmatter description is the only text an agent sees before
deciding to load the skill — every visual type in the selection table must deciding to load the skill — every visual type in the selection table must
@@ -15,6 +15,8 @@ Five drift classes, each of which has shipped before:
text a user reads *before installing*, so by ADR 0004's own argument they text a user reads *before installing*, so by ADR 0004's own argument they
need every type's lexical hook too - and nothing else notices when they need every type's lexical hook too - and nothing else notices when they
drift, because they are four separate copies of one sentence. drift, because they are four separate copies of one sentence.
7. Factory Droid's README install commands and native manifest path must agree
with the package metadata instead of becoming a second hand-maintained API.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,6 +42,8 @@ PROFILE_SURFACES = (
Path("commands/profile.md"), Path("commands/profile.md"),
Path("prompts/profile.md"), Path("prompts/profile.md"),
) )
FACTORY_MANIFEST = Path(".factory-plugin/plugin.json")
FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json")
def normalized(text: str) -> str: def normalized(text: str) -> str:
@@ -156,10 +160,56 @@ def check_profile_surfaces(errors: list[str], root: Path) -> None:
) )
def check_factory_install_surface(errors: list[str], root: Path) -> None:
markdown = (root / "README.md").read_text(encoding="utf-8")
manifest = json.loads((root / FACTORY_MANIFEST).read_text(encoding="utf-8"))
marketplace = json.loads((root / FACTORY_MARKETPLACE).read_text(encoding="utf-8"))
code_blocks = re.findall(
r"^```[^\n]*\n(.*?)^```[ \t]*$", markdown, re.MULTILINE | re.DOTALL
)
marketplace_command = f"droid plugin marketplace add {manifest['repository']}"
install_command = f"droid plugin install {manifest['name']}@{marketplace['name']}"
command_blocks = [
[line.strip() for line in block.splitlines() if line.strip()]
for block in code_blocks
]
install_is_documented = any(
any(
line == install_command or line.startswith(f"{install_command} ")
for line in lines[lines.index(marketplace_command) + 1 :]
)
for lines in command_blocks
if marketplace_command in lines
)
if not install_is_documented:
errors.append(
"README Factory install block must match native metadata: "
f"`{marketplace_command}` then `{install_command}`"
)
native_directory = f"{FACTORY_MANIFEST.parent.as_posix()}/"
architecture_blocks = [
block
for block in code_blocks
if "diagram-design/" in block and "commands/" in block
]
native_path_is_documented = any(
line.lstrip(" │├─└").startswith(native_directory)
for block in architecture_blocks
for line in block.splitlines()
)
if not native_path_is_documented:
errors.append(
f"README architecture tree must list Factory's native {native_directory} path"
)
MANIFEST_DESCRIPTIONS = ( MANIFEST_DESCRIPTIONS = (
(Path(".claude-plugin/plugin.json"), ("description",)), (Path(".claude-plugin/plugin.json"), ("description",)),
(Path(".claude-plugin/marketplace.json"), ("description",)), (Path(".claude-plugin/marketplace.json"), ("description",)),
(Path(".codex-plugin/plugin.json"), ("description", "longDescription")), (Path(".codex-plugin/plugin.json"), ("description", "longDescription")),
(FACTORY_MANIFEST, ("description",)),
) )
@@ -212,6 +262,7 @@ def main() -> int:
errors: list[str] = [] errors: list[str] = []
check_description(errors) check_description(errors)
check_manifest_descriptions(errors, ROOT) check_manifest_descriptions(errors, ROOT)
check_factory_install_surface(errors, ROOT)
check_gallery(errors) check_gallery(errors)
check_readme_tree(errors) check_readme_tree(errors)
check_skill_reference_links( check_skill_reference_links(
@@ -227,7 +278,7 @@ def main() -> int:
return 1 return 1
print( print(
"OK docs sync: description hooks, gallery reachability, README tree, " "OK docs sync: description hooks, gallery reachability, README tree, "
"reference links, profile surfaces, manifest descriptions" "reference links, profile surfaces, manifest descriptions, Factory install contract"
) )
return 0 return 0
+85 -8
View File
@@ -15,9 +15,21 @@ PLUGIN_NAME = "diagram-design"
MANIFEST_PATHS = { MANIFEST_PATHS = {
"Claude": Path(".claude-plugin/plugin.json"), "Claude": Path(".claude-plugin/plugin.json"),
"Codex": Path(".codex-plugin/plugin.json"), "Codex": Path(".codex-plugin/plugin.json"),
"Factory": Path(".factory-plugin/plugin.json"),
} }
CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json") CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json")
CODEX_MARKETPLACE = Path(".agents/plugins/marketplace.json") CODEX_MARKETPLACE = Path(".agents/plugins/marketplace.json")
FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json")
SHARED_MANIFEST_FIELDS = (
"name",
"description",
"version",
"author",
"homepage",
"repository",
"license",
"keywords",
)
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
@@ -36,7 +48,20 @@ def load_json(path: Path, errors: list[str]) -> dict[str, Any] | None:
return payload return payload
def load_base_json(root: Path, base_ref: str, relative: Path, errors: list[str]) -> dict[str, Any] | None: def base_path_exists(root: Path, base_ref: str, relative: Path) -> bool:
result = subprocess.run(
["git", "cat-file", "-e", f"{base_ref}:{relative.as_posix()}"],
cwd=root,
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
def load_base_json(
root: Path, base_ref: str, relative: Path, errors: list[str]
) -> dict[str, Any] | None:
result = subprocess.run( result = subprocess.run(
["git", "show", f"{base_ref}:{relative.as_posix()}"], ["git", "show", f"{base_ref}:{relative.as_posix()}"],
cwd=root, cwd=root,
@@ -125,10 +150,26 @@ def verify_versions(
version_values = list(current_versions.values()) version_values = list(current_versions.values())
if any(version != version_values[0] for version in version_values[1:]): if any(version != version_values[0] for version in version_values[1:]):
rendered = ", ".join(f"{label}={value!r}" for label, value in current_versions.items()) rendered = ", ".join(f"{label}={value!r}" for label, value in current_versions.items())
errors.append(f"Claude and Codex manifest versions must match: {rendered}") errors.append(f"plugin manifest versions must match: {rendered}")
base_check = subprocess.run(
["git", "rev-parse", "--verify", f"{base_ref}^{{commit}}"],
cwd=root,
check=False,
capture_output=True,
text=True,
)
if base_check.returncode != 0:
detail = base_check.stderr.strip() or "not a commit"
errors.append(f"could not resolve base ref {base_ref!r}: {detail}")
return
base_manifest_count = 0
for label, relative in MANIFEST_PATHS.items(): for label, relative in MANIFEST_PATHS.items():
current = parse_semver(current_versions.get(label), f"current {label}", errors) current = parse_semver(current_versions.get(label), f"current {label}", errors)
if not base_path_exists(root, base_ref, relative):
continue
base_manifest_count += 1
base_manifest = load_base_json(root, base_ref, relative, errors) base_manifest = load_base_json(root, base_ref, relative, errors)
if base_manifest is None: if base_manifest is None:
continue continue
@@ -138,6 +179,11 @@ def verify_versions(
f"{label} manifest version must increase relative to {base_ref}: " f"{label} manifest version must increase relative to {base_ref}: "
f"{base_manifest.get('version')} -> {current_versions.get(label)}" f"{base_manifest.get('version')} -> {current_versions.get(label)}"
) )
if base_manifest_count == 0:
errors.append(
f"no synchronized plugin manifest exists at {base_ref}; "
"cannot establish that the package version advanced"
)
def verify_manifest_identity(manifests: dict[str, dict[str, Any]], errors: list[str]) -> None: def verify_manifest_identity(manifests: dict[str, dict[str, Any]], errors: list[str]) -> None:
@@ -146,20 +192,40 @@ def verify_manifest_identity(manifests: dict[str, dict[str, Any]], errors: list[
errors.append( errors.append(
f"{label} manifest name must remain {PLUGIN_NAME!r}; got {payload.get('name')!r}" f"{label} manifest name must remain {PLUGIN_NAME!r}; got {payload.get('name')!r}"
) )
reference_label = next(iter(MANIFEST_PATHS))
reference = manifests[reference_label]
for label, payload in manifests.items():
if label == reference_label:
continue
for field in SHARED_MANIFEST_FIELDS:
if payload.get(field) != reference.get(field):
errors.append(
f"{label} manifest {field!r} must match {reference_label}; "
f"got {payload.get(field)!r}"
)
def verify_marketplaces(root: Path, errors: list[str]) -> None: def verify_marketplaces(root: Path, errors: list[str]) -> None:
claude_marketplace = load_json(root / CLAUDE_MARKETPLACE, errors) claude_marketplace = load_json(root / CLAUDE_MARKETPLACE, errors)
codex_marketplace = load_json(root / CODEX_MARKETPLACE, errors) codex_marketplace = load_json(root / CODEX_MARKETPLACE, errors)
if claude_marketplace is None or codex_marketplace is None: factory_marketplace = load_json(root / FACTORY_MARKETPLACE, errors)
if (
claude_marketplace is None
or codex_marketplace is None
or factory_marketplace is None
):
return return
claude_entry = find_plugin_entry(claude_marketplace, "Claude", errors) claude_entry = find_plugin_entry(claude_marketplace, "Claude", errors)
codex_entry = find_plugin_entry(codex_marketplace, "Codex", errors) codex_entry = find_plugin_entry(codex_marketplace, "Codex", errors)
if claude_entry is None or codex_entry is None: factory_entry = find_plugin_entry(factory_marketplace, "Factory", errors)
if claude_entry is None or codex_entry is None or factory_entry is None:
return return
claude_root = resolve_local_path(root, claude_entry.get("source"), "Claude plugin source", errors) claude_root = resolve_local_path(root, claude_entry.get("source"), "Claude plugin source", errors)
factory_root = resolve_local_path(
root, factory_entry.get("source"), "Factory plugin source", errors
)
codex_source = codex_entry.get("source") codex_source = codex_entry.get("source")
if not isinstance(codex_source, dict) or codex_source.get("source") != "local": if not isinstance(codex_source, dict) or codex_source.get("source") != "local":
@@ -183,15 +249,26 @@ def verify_marketplaces(root: Path, errors: list[str]) -> None:
errors.append("Claude marketplace target does not contain .claude-plugin/plugin.json") errors.append("Claude marketplace target does not contain .claude-plugin/plugin.json")
if codex_root is not None and not (codex_root / MANIFEST_PATHS["Codex"]).is_file(): if codex_root is not None and not (codex_root / MANIFEST_PATHS["Codex"]).is_file():
errors.append("Codex marketplace target does not contain .codex-plugin/plugin.json") errors.append("Codex marketplace target does not contain .codex-plugin/plugin.json")
if claude_root is not None and codex_root is not None and claude_root != codex_root: if factory_root is not None and not (factory_root / MANIFEST_PATHS["Factory"]).is_file():
errors.append("Claude and Codex marketplaces must package the same plugin root") errors.append("Factory marketplace target does not contain .factory-plugin/plugin.json")
plugin_root = codex_root or claude_root plugin_roots = [
plugin_root
for plugin_root in (claude_root, codex_root, factory_root)
if plugin_root is not None
]
if plugin_roots and any(plugin_root != plugin_roots[0] for plugin_root in plugin_roots[1:]):
errors.append("Claude, Codex, and Factory marketplaces must package the same plugin root")
plugin_root = codex_root or claude_root or factory_root
if plugin_root is None: if plugin_root is None:
return return
skill = plugin_root / "skills" / PLUGIN_NAME / "SKILL.md" skill = plugin_root / "skills" / PLUGIN_NAME / "SKILL.md"
if not skill.is_file(): if not skill.is_file():
errors.append(f"packaged skill is missing: {skill.relative_to(root)}") errors.append(f"packaged skill is missing: {skill.relative_to(root)}")
commands = plugin_root / "commands"
if not commands.is_dir() or not any(commands.glob("*.md")):
errors.append("packaged commands are missing from the shared plugin root")
def verify_codex_skill_path(root: Path, codex_manifest: dict[str, Any], errors: list[str]) -> None: def verify_codex_skill_path(root: Path, codex_manifest: dict[str, Any], errors: list[str]) -> None:
@@ -232,7 +309,7 @@ def main() -> int:
return 1 return 1
versions = load_json(ROOT / MANIFEST_PATHS["Claude"], [])["version"] versions = load_json(ROOT / MANIFEST_PATHS["Claude"], [])["version"]
print( print(
f"OK plugin package: Claude and Codex {versions}, " f"OK plugin package: Claude, Codex, and Factory {versions}, "
f"marketplace paths, and packaged skill" f"marketplace paths, and packaged skill"
) )
return 0 return 0
@@ -198,6 +198,12 @@ Use the installed-skill location exposed by the current agent when available. Ot
1. `~/.claude/skills/<skill-name>/` (user install) 1. `~/.claude/skills/<skill-name>/` (user install)
2. `.claude/skills/<skill-name>/` (project install) 2. `.claude/skills/<skill-name>/` (project install)
**Factory Droid:**
1. `~/.factory/skills/<skill-name>/` (personal install)
2. `.factory/skills/<skill-name>/` from the current directory through the repo root (folder-specific or project install)
3. The active path shown in `/skills` under **Plugins**; installed plugins keep the shared `skills/<skill-name>/` directory inside Droid's plugin cache
Finally, check any path the user provides explicitly. If the skill is still not found, ask the user to confirm the name or provide its path. Finally, check any path the user provides explicitly. If the skill is still not found, ask the user to confirm the name or provide its path.
### Step 2 — read token sources ### Step 2 — read token sources