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
Every asset sits at the spec’s default skills/ and agents/ location within
PLUGIN_ROOT, and the manifest therefore declares no component paths at
all.
That layout 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 stay at their defaults and the manifest
remains metadata-only.
Note
PLUGIN_ROOT is the single source of truth, and it is directly
installable: claude --plugin-dir .claude loads the same components a consumer
gets, with no packing step in between. pack_plugin() mirrors that tree into
the archive rather than rearranging it, so there is no symlink anywhere and no
second copy of any skill.
Note
Three copies of the version travel with this plugin, and none is maintained by
hand. pack_plugin() injects the running __version__ into the manifest it
writes to the archive, while the checked-in manifest and the catalog entry are
both stamped by the release freeze
(PrepareRelease.freeze_plugin_manifest_version() and
PrepareRelease.freeze_marketplace_pin()).
The CLI compares the plugin manifest’s string against a user’s installed
copy to decide whether an update is due, so a value that went stale would
silently strand everyone on the plugin they already had. The Desktop app waits
on neither string. Because the clients disagree, all three copies carry the
same string, whichever one a client reads. docs/claude-code-plugin.md § How
the pin moves holds the measurements.
Deriving each copy keeps the single 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, and keeps the post-release .devN bump from advertising
a release that does not exist.
Note
The marketplace entry is a git-subdir source naming PLUGIN_ROOT in this
repository, and its two halves move differently.
PrepareRelease.freeze_marketplace_pin() writes both on a release commit;
PrepareRelease.unfreeze_marketplace_ref() then walks the ref back to the
default branch and leaves the version on the release. So a released tree
installs exactly that release, while the default branch tracks itself, which is
the only state the app’s Sync automatically can ever observe a change in.
That split is why the tag is not simply pinned everywhere: a tag never moves, so
an entry frozen to one turns automatic sync into a no-op until the next release.
It also survives a layout change, where a pin naming the last release cannot: the
plugin root moved into PLUGIN_ROOT in this very cycle, and a catalog
pinned to the previous tag pointed at a tree with no manifest in it.
An archive source pointing at the release asset was the previous shape, and it
worked in the CLI alone. The claude.ai ingester behind the Desktop and Cowork
plugin panels rejects it with External plugin source type ‘archive’ is not
supported. Supported types: git-subdir, github, url, surfaced in the app as a
bare “Marketplace sync failed” naming no cause. git-subdir is the narrowest
supported type: it clones this one directory through a partial clone rather than
the whole repository.
Caution
The entry pins a tag through ref, not a commit through sha, so it inherits
whatever the tag points at. A tag is movable and a sha is not, which is the
stronger guarantee of the two; the tag is kept because it is what
PrepareRelease.freeze_marketplace_pin() can write from the version it is
already freezing, where a SHA is only knowable after the freeze commit exists.
The release asset is no longer what a marketplace install fetches, so a release
publishing without it leaves /plugin install working. It still strands the
manual Desktop and Cowork upload, the only route for anyone not syncing this
catalog, which is why a failed extra-assets keeps blocking publish-release.
- repomatic.tooling.plugin.PLUGIN_ROOT = '.claude'¶
Directory that is the plugin, relative to the repository root.
It already holds
skills/andagents/at the locations the plugin spec scans, so naming it as the marketplace entry’spathpublishes the tree as-is.Caution
The whole directory is what a consumer clones, which is wider than the archive:
pack_plugin()admits only what the component registry declares, while agit-subdirinstall carries every file sitting here. Anything added under this path ships to every consumer, so keep it to the components themselves.
- repomatic.tooling.plugin.MANIFEST_PATH = '.claude-plugin/plugin.json'¶
Location of the plugin manifest, relative to the plugin root.
The same path in both places it appears: under
PLUGIN_ROOTin the tree, wherepack_plugin()reads it and a marketplace install finds it, and at the top of the archive, where Claude Code looks for it. Join it onto the repository root throughREPO_MANIFEST_PATH, never on its own.
- repomatic.tooling.plugin.REPO_MANIFEST_PATH = '.claude/.claude-plugin/plugin.json'¶
Location of the plugin manifest, relative to the repository root.
- 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. Nothing else names it: the marketplace stopped pointing at the asset when it moved to agit-subdirsource, so renaming it now reaches only the upload instructions.
- 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.18.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, the layout mirrors
PLUGIN_ROOT, minus everything the component registry does not declare: 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_settingsowns 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. The comparison is made on the parsed documents, not the rendered text, because the two serializers disagree on more than the indent root negotiates:json.dumps()expands every array one item per line, while Biome collapses one that fits its line width. Comparing the text reported drift on a fileformat-jsonhad already settled, and the two rewrote it past each other on every run, each opening a pull request undoing the other’s.A first write still lands in this module’s own style, which
format-jsonthen settles once. That is the same one-reformat cost_biome_json_indentaccepts when it has to guess.