repomatic.tooling.plugin module¶
Distribution of the bundled skills and agents as a Claude Code plugin.
Two halves of the same story, kept together because they share the plugin’s identity constants:
pack_plugin()assembles the zip the release engine attaches to every GitHub release, from the manifest and asset directories already in the tree.merge_plugin_settings()writes the marketplace and enablement wiring into a consumer’s Claude Code settings, so a downstream repository can install the plugin instead of carrying copied skill files.
Caution
pack_plugin() relocates each asset into the spec’s default skills/
and agents/ directories, rather than mirroring the .claude/ layout it reads
them from, and the manifest therefore declares no component paths at all.
That asymmetry is not a stylistic choice. A manifest naming individual agent
files ("agents": ["./.claude/agents/qa-engineer.md", ...], the only form the
published
schema accepts,
since it constrains the field to paths ending in .md) passes claude plugin
validate –strict and then loads zero agents at runtime, silently. Naming
the directory instead fails validation outright. The default location is the only
shape that actually works, verified against Claude Code 2.1.220 by loading the
packed archive and counting components with claude plugin details. skills
does honor a custom directory, but there is no reason to keep one half on the
mechanism that misbehaves, so both travel to their defaults and the manifest
stays metadata-only.
Note
.claude/skills/ and .claude/agents/ remain the single source of truth: the
relocation happens only inside the archive, so there is no symlink anywhere and
no second copy of any skill in the tree. The trade-off is that the repository
root is not itself an installable plugin: test a change by packing it and
pointing claude --plugin-dir at the unpacked archive.
Note
The checked-in manifest carries no version: pack_plugin() injects the
running __version__ into the copy it writes to the archive.
Claude Code compares that string against a user’s installed copy to decide
whether an update is due, so a hand-maintained value that went stale would
silently strand everyone on the plugin they already had. Deriving it at pack time
makes it impossible to forget, and keeps the one repomatic-specific
[[tool.bumpversion.files]] entry out of a [tool.bumpversion] block that
sync-bumpversion regenerates from a bundled template shared with every
downstream repository.
Note
The marketplace entry is an archive source pointing at the release asset, and
its URL ratchets forward: PrepareRelease.freeze_marketplace_archive_url()
rewrites it to /releases/download/v{X.Y.Z}/ on each release commit, and nothing
walks it back. So the default branch always names the newest published release,
and a catalog added at a tag installs that tag’s plugin. The URL is never a
latest redirect except before the very first release, and never a .devN tag.
Caution
The entry still carries no sha256. The archive is byte-deterministic, so a
digest could in principle be committed alongside the pin, but only if the release
runner reproduces those bytes exactly: ZIP_DEFLATED output depends on the zlib
build behind CPython, and a one-byte difference would fail every install with
Plugin archive integrity check failed rather than degrading. Integrity comes
from the attestation the engine’s extra-assets job generates instead. Switching
to ZIP_STORED would make a committed digest safe, at the cost of a larger asset.
Independently of that: a release that publishes without this asset breaks
/plugin install until the next one, which is why a failed extra-assets now
blocks publish-release.
- repomatic.tooling.plugin.MANIFEST_PATH = '.claude-plugin/plugin.json'¶
Location of the plugin manifest.
The same path in both places it appears: relative to the repository root, where
pack_plugin()reads it, and relative to the plugin root inside the archive, where Claude Code looks for it.
- repomatic.tooling.plugin.MARKETPLACE_PATH = '.claude-plugin/marketplace.json'¶
Location of the marketplace catalog, relative to the repository root.
- repomatic.tooling.plugin.PLUGIN_NAME = 'repomatic'¶
The plugin’s
name, which namespaces every skill and agent it ships.Users type it as
/plugin install repomatic@kdeldyckeand see it in the scoped component names (repomatic:qa-engineer). Renaming it breaks every existing install, so it lives here as a constant and is asserted against the manifest rather than read from it.
- repomatic.tooling.plugin.MARKETPLACE_NAME = 'kdeldycke'¶
The marketplace’s
name, the catalog this plugin is published in.Named after the owner rather than the project, so sibling repositories can be listed in the same catalog later. Like
PLUGIN_NAME, renaming it breaks every existing install.
- repomatic.tooling.plugin.MARKETPLACE_REPO = 'kdeldycke/repomatic'¶
Repository a consumer registers to reach
MARKETPLACE_PATH.
- repomatic.tooling.plugin.BIOME_DEFAULT_INDENT: Final[str] = '\t'¶
Indent
format-jsonwrites when no Biome configuration overrides it.Biome’s own default, so a repository declaring nothing gets a rendered document the formatter already agrees with.
- repomatic.tooling.plugin.BIOME_DEFAULT_INDENT_WIDTH: Final[int] = 2¶
Spaces per level Biome assumes when a config asks for spaces without a width.
- repomatic.tooling.plugin.ARCHIVE_NAME = 'repomatic-claude-plugin.zip'¶
Filename of the release asset
pack_plugin()produces.Carries
claudebecause a barerepomatic-plugin.zipreads backwards: packaging names an extension after its host first (pytest-cov,mdformat-gfm), so that filename announces a plugin for repomatic on a release page, which is also what “plugin” means for the mdformat entries oftool_registry. The name mirrors the spec’s own.claude-plugin/directory instead.Also the default
--outputofrepomatic pack-plugin, so the release job never spells it. It still appears in[tool.repomatic] release-assetsand in therelease-asset-run-artifact name the engine matches, which TOML and YAML cannot read from here;tests/test_workflows.pyholds all three equal.freeze_marketplace_archive_url()rewrites the marketplace URL’s trailing filename from here too, so a rename reaches every consumer through one constant.
- repomatic.tooling.plugin.ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)¶
Fixed modification time stamped on every archive member.
The earliest timestamp the ZIP format can represent. Together with a sorted member list and an explicit file mode, it makes
pack_plugin()byte-deterministic, so re-packing an unchanged tree yields an identical archive. That matters more here than it usually would: with nosha256pin in the marketplace entry, the archive’s own digest is what Claude Code falls back to for change detection.
- repomatic.tooling.plugin.FILE_MODE = 420¶
Permission bits stamped on every archive member.
Stamped explicitly rather than copied from disk so the archive does not vary with the packing runner’s umask.
- repomatic.tooling.plugin.AGENTS_DIR = 'agents'¶
Directory the plugin spec scans for agent definitions, inside the plugin root.
- repomatic.tooling.plugin.SKILLS_DIR = 'skills'¶
Directory the plugin spec scans for skill folders, inside the plugin root.
- repomatic.tooling.plugin.pack_plugin(repo_root, output, version='7.14.1.dev0')[source]¶
Pack the manifest and its assets into an installable plugin archive.
The archive holds a single top-level folder named after the plugin. That is one of the two layouts Claude Code accepts, and the one that makes unzip && claude –plugin-dir repomatic work on the downloaded asset. Inside it, assets sit at the spec’s default locations rather than the
.claude/paths they are read from: see the module docstring for why.- Parameters:
- Return type:
- Returns:
Archive member names, sorted.
- Raises:
FileNotFoundError – If the manifest, an agent file or a skill folder is missing.
TypeError – If the manifest is not a JSON object.
- repomatic.tooling.plugin.render_plugin_settings(existing='', indent='\\t')[source]¶
Merge the plugin wiring into an existing settings document.
Only the two keys
_plugin_settings()owns are touched, and within them only the entries this plugin and marketplace are named by: a repository’s own permissions, hooks and any unrelated marketplace survive untouched.Sorted keys, and indent whichever way
format-jsonwrites JSON in the consuming repository, so writing the file leaves no drift for the formatter to raise a pull request about. Biome preserves key order, which is why only the indent has to be negotiated.
- repomatic.tooling.plugin.merge_plugin_settings(target, root=None)[source]¶
Write the plugin wiring into target, creating the file if absent.
Idempotent: re-running against an already-wired document rewrites nothing and returns
False, sorepomatic initreports it as unchanged. That holds only while the rendered indent matches the repository’s own, which is why root is read rather than assumed: a repository declaring spaces would otherwise see this andformat-jsonrewrite the file past each other on every run, each opening a pull request undoing the other’s.