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@kdeldycke and 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-json writes 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 claude because a bare repomatic-plugin.zip reads 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 of tool_registry. The name mirrors the spec’s own .claude-plugin/ directory instead.

Also the default --output of repomatic pack-plugin, so the release job never spells it. It still appears in [tool.repomatic] release-assets and in the release-asset- run-artifact name the engine matches, which TOML and YAML cannot read from here; tests/test_workflows.py holds 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 no sha256 pin 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:
  • repo_root (Path) – Repository root the assets are read from.

  • output (Path) – Destination .zip path. Parent directories are created.

  • version (str) – Version stamped into the packaged manifest.

Return type:

list[str]

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-json writes 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.

Parameters:
  • existing (str) – Current file content, or an empty string when absent.

  • indent (str) – One level of indentation, from _biome_json_indent().

Return type:

str

Returns:

The merged document, newline-terminated.

Raises:

TypeError – If existing is not a JSON object.

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, so repomatic init reports 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 and format-json rewrite the file past each other on every run, each opening a pull request undoing the other’s.

Parameters:
  • target (Path) – Path to the Claude Code settings file to update.

  • root (Path | None) – Repository root whose Biome config sets the indent. Defaults to target’s own directory, which is right only for a root-level file.

Return type:

bool

Returns:

Whether the file was created or modified.