skillup

module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: MIT

README

skillup

Version the segments of a Claude Code plugin marketplace from git history — without depending on tags.

Built on gtb — an opinionated, batteries-included framework for Go CLI tools and services.

Documentation: https://skillup.phpboyscout.uk · Latest release: v0.1.0

Status: released, pre-1.0. v0.1.0 is cut, published to the Go module proxy, and gating merge requests on phpboyscout/claude-code-plugins. The history is stable — install from the module proxy, not a clone.

Being pre-1.0, the CLI surface may still change between minor versions. Pin the version your pipeline installs.

The original investigation, what it found and the recommendation are in the spike report on the wiki.

The problem

A plugin marketplace monorepo holds many plugins ("segments"), each with its own plugin.json carrying its own version, each moving independently.

That version field is the distribution mechanism, not a label. Claude Code caches at ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ and resolves no git ref — consumers track the default branch. From the Claude Code docs: "users only receive updates when you change this field."

So a version that does not move does not delay a change. It withholds it, silently and indefinitely. There is no error and nothing in CI to notice.

This is not hypothetical. On its first run against a real marketplace, skillup found a segment that had shipped nothing since it was created — two commits of new files that had reached nobody.

Why not an existing tool

Tool Why not
releaser-pleaser No monorepo support (upstream issue #12, open since 2024)
release-please Has the monorepo model and a JSON updater, but is GitHub-only
commit-and-tag-version Bumps many files to one version, not N independent ones
cocogitto Fits — but derives its baseline from the last git tag
claude plugin tag Tags and validates correctly, but computes no versions

Cocogitto plus claude plugin tag composes, and was proven to work. It has one structural flaw:

A tag baseline makes tags load-bearing state. Somebody must cut one promptly after every merge, forever. Delay it by a single merge and two different states of main ship under the same version. Run it on a repository with no tags and it proposes 0.1.0 for everything.

What skillup does differently

It takes each segment's baseline from the git history of that segment's own plugin.json — the commit at which its version last changed.

Tags stop being input and become decoration. The consequences:

  • Correct on a repository that has never been tagged. No seeding, no bootstrap.
  • A missed or delayed tag costs visibility, never correctness.
  • Tags can be back-filled, because the commit where each version was set is recoverable from history.

Commands

skillup plan    --path .   # what would change; changes nothing
skillup apply   --path .   # writes plugin.json only — no commit, no tag, no push
skillup check   --path .   # CI gate; non-zero when a segment is behind
skillup tag     --path .   # <segment>--v<version> tags, at the commit that set them
skillup version            # which build produced this answer
$ skillup plan --path .
  alpha                    0.4.0    -> 0.5.0    (minor, 3 commit(s))
  bravo                    0.22.0   -> 0.22.1   (patch, 1 commit(s))
  charlie                  1.2.0    -> 2.0.0    (major, 1 commit(s))
The workflow it is built for

apply writes manifests and nothing else — deliberately. The caller commits the result alongside the change that earned it, so the repository is never in a state where a segment's content and its version disagree.

In CI that means running on the merge request, not after merge.

Rules it enforces
  • Scoping is by changed path, not by the commit's scope label. A commit messaged feat(forge): that touches no forge file bumps nothing.
  • It never lowers a version. Conventional commits under-describe some changes — removing a skill breaks a consumer but usually lands as refactor: — and reaching 1.0 is a promise, not an arithmetic result. Automation raises the floor; it does not overrule an author.
  • Below 1.0, a breaking change is a minor bump.
  • Writes preserve the file byte-for-byte apart from the version. No JSON round-trip: re-serialising escapes non-ASCII and reflows inline arrays, turning a one-character change into an eighteen-line diff.
  • Idempotent. It computes from the last committed version, so running twice before committing does not bump twice.
Consistency

check also reports where the catalogue and the segments on disk disagree: a segment nobody lists (so nobody can install it), a marketplace.json version contradicting plugin.json — which matters because the catalogue wins for resolution — and a catalogue version that is not semver at all.

A catalogue entry pointing at a missing directory is not one of these: it fails the run outright, before the consistency pass. See the check reference.

In production

skillup gates every merge request on phpboyscout/claude-code-plugins, the marketplace it was written for:

version-check:
  stage: version
  image: registry.gitlab.com/phpboyscout/images/dev-tools:v0.3.3
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  variables:
    GIT_DEPTH: "0"          # skillup reads whole history; shallow is silently wrong
  before_script:
    - go install gitlab.com/phpboyscout/skillup/cmd/skillup@v0.1.0
  script:
    - skillup check --path .

The job only reads. CI cannot write the bump for you on either forge — a job-token push triggers no pipeline, so the merge request would block forever on a head commit nothing can validate. The GitLab and GitHub how-tos work through it.

Prerequisites

  • Go 1.26.5 or newer — required to build and install.

The following tools are only needed for development (they are not required to install or run the binary). Each is invoked by a just recipe:

Install

go install gitlab.com/phpboyscout/skillup/cmd/skillup@v0.1.0

Pin the version rather than tracking @latest. This tool decides whether a change ships; it should not change underneath a repository without a commit saying so. Renovate tracks the pin like any other Go dependency.

The main package lives at cmd/skillup/, so the install path ends in /cmd/skillup — installing the module root would fail because it has no main package.

Every release also ships pre-built binaries for Linux, macOS and Windows on amd64 and arm64, plus a checksums.txt, attached to the GitLab release. Grab one when there is no Go toolchain to hand.

The full instructions — including building from a clone — are in Install skillup.

Build & run

This repository ships a justfile with the common tasks. Build the binary with the default recipe:

just            # tidy + generate + build → bin/skillup
just build      # the same, explicitly

Then run it:

./bin/skillup --help

To install the built binary into $GOPATH/bin:

just install

Develop

Clone & verify
just test        # unit tests with coverage
just test-race   # tests under the race detector
just lint        # golangci-lint
just check       # pre-commit hooks across the tree
just ci          # the full local CI suite (tidy, generate, test, test-race, lint)

Run just ci before opening a pull/merge request — it mirrors what CI runs.

Project layout
Path What lives there
cmd/skillup/ The main entry point (main.go).
pkg/segment/ The whole of the logic — discovery, git history, version arithmetic, manifest writing, tagging, consistency.
pkg/cmd/{plan,apply,check,tag}/ Thin command layers over pkg/segment — flags in, rendering out.
pkg/cmd/root/ The root command — wiring and feature flags. Generated.
internal/version/ Build-time version metadata (injected via ldflags).
docs/ The documentation site source (served by zensical).
.gtb/manifest.yaml The generator manifest (see below).

pkg/segment is the part worth reading. Each pkg/cmd/<command>/main.go calls into it and formats the answer; none of them decide anything.

File in pkg/segment What it answers
plan.go Which segments exist, and what state each one is in
git.go Where a segment's baseline commit is, and what touched it since
version.go Semver arithmetic and the bump rules
manifest.go Reading and byte-preserving writing of plugin.json
commit.go Committing and pushing, when asked
consistency.go Where the catalogue and the segments on disk disagree
The manifest & regeneration model

skillup is scaffolded and kept in sync by GTB's generator. The .gtb/manifest.yaml file is the source of truth for the command tree and project settings. Two flows operate on it:

  • gtb generate command <name> — scaffold a new command, recording it in the manifest.
  • gtb regenerate — re-render the project from the manifest and the current GTB templates.

Generated files are hash-tracked in the manifest. If you hand-edit a generated file, the next regenerate detects the change as a conflict and prompts before overwriting it — so your edits are never silently lost. This README is one of those files: it is yours to edit, and regeneration will ask before replacing it.

Do not hand-edit generated files expecting silent re-generation. Put custom logic in your own packages, or accept the conflict prompt on the next regenerate.

If a generated file is one you deliberately own — a justfile you have extended, a Dockerfile, a real README.md — mark it hands-off in .gtb/ignore so regenerate stops re-rendering it and stops prompting:

gtb ignore add justfile      # or edit .gtb/ignore by hand
gtb ignore check justfile    # confirm it is now ignored, and by which rule

The scaffold ships a commented .gtb/ignore explaining the syntax. See the Configure Generator Ignore Rules how-to.

Configuration — there isn't any

skillup takes everything it needs from arguments and the repository in front of it. There is no config file and nothing to initialise.

The framework's commands are therefore marked auxiliary in pkg/cmd/root/cmd.go, which skips the config bootstrap entirely. One consequence worth knowing if you extend it: on that path Props.Config is nil, so reach for props.ViewOrNil(p) rather than p.GetConfigView(), which dereferences it.

Built-ins, deliberately disabled

GTB ships several commands by default. update, init, mcp, docs, doctor and changelog are all disabled here, taking the surface from thirteen commands to seven.

skillup reads git history, compares it to manifests and writes a version. It serves no MCP surface, ships no docs site, has nothing to initialise, and self-updates through whatever installed it. For a tool that runs in CI, surface area is something to justify rather than inherit.

version stays — it is not gated, and "which build produced this answer" is a fair question to ask of a release tool.

Change the feature set with gtb enable <feature> / gtb disable <feature> — this updates .gtb/manifest.yaml and re-renders the root command, so the change survives gtb regenerate project. Do not hand-edit pkg/cmd/root/cmd.go; it is generated and will be overwritten.

Documentation

The site is published at https://skillup.phpboyscout.uk, deployed from main by the zensical-pages CI job. The source lives in docs/, structured along Diátaxis lines:

Your first run Install it, point it at a marketplace you create, watch it catch a change that would have reached nobody.
Install skillup Module proxy, release binaries, or a clone.
Set up the gate in GitLab · in GitHub Make a merge request fail when a version is behind.
Command reference Every flag, exit code and guarantee.
Why not tags? The one design decision that distinguishes it from the alternatives.

Serve it locally with:

just docs-serve

Releasing

Releases are driven by Conventional Commits through the pipeline in .gitlab-ci.yml, assembled from phpboyscout/cicd components. The chain:

  1. A merge to main triggers releaser-pleaser, which opens (or updates) a Release MR carrying the computed version and the generated changelog.
  2. Merging that MR cuts the vX.Y.Z tag.
  3. The tag pipeline fans out to GoReleaser (.goreleaser.yaml — binaries for six OS/arch pairs, attached to the GitLab release) and the zensical-pages docs deploy.

CHANGELOG.md is generated by releaser-pleaser and should not be hand-edited.

Merge requests are the only quality gate — pushing to main runs nothing but releaser-pleaser, whose own MR gets the full gate before it lands.

See also the GTB release guide: https://gtb.phpboyscout.uk/how-to/custom-release-source/

One-off setup that bites late. GITLAB_TOKEN is a protected group variable, so it reaches a tag pipeline only if v* is a protected tag. This project's first release failed at goreleaser's first step for exactly that reason, weeks after the project was created and everything looked healthy. The pattern is matched rather than snapshotted, so protecting v* afterwards fixes the existing tag too — retry the job rather than re-cutting.

Contributing

  • Follow Conventional Commits for every commit — the changelog and version bumps are computed from them.
  • Run just ci before opening a pull/merge request.
  • Do not hand-edit generated files (those tracked in .gtb/manifest.yaml); the next gtb regenerate will flag them as conflicts. If you mean to own a file permanently, add it to .gtb/ignore (or run gtb ignore add <path>).

Licence

MIT — see LICENSE.

Go deeper

GTB documentation:

Directories

Path Synopsis
cmd
skillup command
Activates OS keychain support by blank-importing the go-keyring-backed Backend.
Activates OS keychain support by blank-importing the go-keyring-backed Backend.
internal
pkg
segment
Package segment computes, writes and tags the versions of the segments in a Claude Code plugin marketplace monorepo.
Package segment computes, writes and tags the versions of the segments in a Claude Code plugin marketplace monorepo.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL