build/skills/migrate/SKILL.md
MUST be invoked before any work involving: the `charly migrate` command (the single idempotent migration that brings any opencharly config up to the latest schema CalVer), the CalVer schema-version stamp (`version: YYYY.DDD.HHMM`), the CUE-anchored HEAD/floor (`charly/schema/version.cue` → `#SchemaVersion`/`#SchemaFloor`), the declarative migration table (`charly/migrations.cue`, applied by the core op-walker in `charly/migrate_engine.go`), the `LatestSchemaVersion()` load-time gate, or adding a new schema cutover as a migration-table entry.
npx skillsauth add overthinkos/overthink-plugins migrateInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
charly migrate is one idempotent command that brings any opencharly config — as far back as the supported schema floor — up to the latest schema CalVer, and only to the latest. There are no sub-verbs to choose between: charly migrate always applies every migration-table step newer than the config, up to HEAD.
charly migrate # migrate every reachable config to the latest schema CalVer
charly migrate --dry-run # print every change migrate would make; touch nothing
The project directory is the current working directory; use the top-level -C / --dir / CHARLY_PROJECT_DIR global to point elsewhere (main() chdir's before dispatch).
The YAML schema version is a CalVer string — version: YYYY.DDD.HHMM, the same fixed-width scheme as image tags (e.g. a HEAD like version: 2026.174.1100). It is CANONICAL fixed-width: a 4-digit year, a 3-digit zero-padded day-of-year, and a 4-digit zero-padded HHMM, so a plain alphanumeric sort of CalVer strings is chronological. ParseCalVer is EXTREMELY STRICT — it accepts ONLY that exact form (no version: 4, no non-padded 2026.45.830); a non-canonical value is "not a CalVer", which the load gate treats as older-than-HEAD so it flows into charly migrate. charly migrate re-stamps every versioned file it brings forward to the canonical HEAD (universalStamp); a value that predates the supported floor — or is not a CalVer at all — is unmigratable and refused untouched (see "How it runs"). Every versioned file carries the stamp:
charly.yml (splitting into per-kind vm.yml / local.yml / … siblings is an optional import: convenience a project MAY use — never the default; see /charly-image:image)~/.config/charly/charly.ymlThe parsed CalVer type, ParseCalVer(string) (CalVer, bool), CalVer.Less, and MustCalVer live in charly/plugin/kit. CUE owns the HEAD and floor: charly/schema/version.cue defines #SchemaVersion (the HEAD CalVer every file is stamped to) and #SchemaFloor (the oldest version migratable FROM). task cue:gen emits charly/spec/version_gen.go with the SchemaVersion / SchemaFloor string consts; kit.LatestSchemaVersion() and kit.SchemaFloor() parse them. There is NO hand-maintained Go HEAD literal — the load-time gate requires kit.LatestSchemaVersion(), and charly migrate refuses anything below kit.SchemaFloor().
version:)Every push of an charly-project repo (one with an charly.yml) carries a fresh annotated release git tag v<YYYY.DDD.HHMM> computed from the current UTC push time — ONE per push, so a repo accumulates MULTIPLE v… tags over time. This is decoupled from the charly.yml version: field: version: is the SCHEMA version (bumped only when a cutover raises #SchemaVersion), whereas the tag marks the push moment. Tag EVERY push — including one at an unchanged version: (a content change, submodule extraction, image drop). Tags are immutable: only ever ADD new ones, never move or force-push an existing tag (so the load-time gate never sees a "newer than supported" CalVer from a re-tag). Every component is fixed-width zero-padded — 4-digit year, 3-digit day-of-year, 4-digit HHMM (v2026.064.1937, v2026.142.1640) — so tags sort chronologically under a plain alphanumeric sort; compute with v$(date -u +%Y.%j.%H%M) (%j and %H%M are already zero-padded). Each repo (the main repo and every box/<distro> submodule) is tagged independently at its own push time; push submodule(s) first, then the superproject, then the tag on the pushed HEAD. Repos without an charly.yml (plugins, pkg/arch) are out of scope. See CLAUDE.md "Post-Execution Policies".
The migration steps are declarative DATA, not code: an ordered list embedded from charly/migrations.cue, validated at process start against the #Migration schema (charly/schema/migration.cue). Each step is stamped with the CalVer of the date it landed and listed chronologically — the order the cutovers were authored in, which is the only correct replay order for an arbitrarily-old config. charly migrate applies every step whose version is newer than the config's stamp; each step is idempotent, so applying the whole set is safe (an already-current file is a no-op).
A step is a small record:
{version: "YYYY.DDD.HHMM", name: "<slug>", touches_host?: bool, ops?: [...#Op] | apply?: "<hook>"}
touches_host: true flags a step that mutates per-host state (~/.config/charly, quadlets, .secrets) — those steps are skipped by remote-cache auto-migration (see below). A step carries EITHER an ops: list OR a single apply: hook, never both.
The op vocabulary — each op is DATA, zero Go. Four generic key transforms cover the common cutovers, all applied by ONE comment-preserving yaml.v3 interpreter (the op-walker in charly/migrate_engine.go):
| Op | Shape | Effect |
|---|---|---|
| rename_key | {from, to, scope, under_kind?} | rename a mapping key from → to |
| delete_key | {key, scope, under_kind?} | drop a mapping key |
| remap_scalar | {key, from, to, under_kind?} | rewrite a scalar VALUE from → to at key |
| move_key | {key, from_parent, to_parent, under_kind?} | relocate a key between parent mappings |
scope is root (top-level keys only) or any (every depth); under_kind further scopes an op to entities of a named kind (e.g. only vm: nodes). None of the four ops needs a line of new Go — the walker interprets the data.
The apply: hook — the deliberate, visible EXCEPTION. A structural reshape the four ops can't express (splitting one node into siblings, folding a list into a map) sets apply: "<hook>" naming a Go function registered in the core goHooks map. This is the ONLY path that runs bespoke Go for a migration, and it is intentionally conspicuous: a table entry carrying apply: is the signal that this step needed real code.
CUE has NO transformation capability. CUE is monotone unification — it can validate, default, require, and close, and nothing more; it CANNOT rewrite a config. So CUE owns exactly two things here: the version pins (#SchemaVersion / #SchemaFloor) and the SHAPE of the declarative table (#Migration validates every entry at startup). Every actual transform is Go — the generic op-walker, or an apply: hook. Do not expect CUE to migrate anything.
The table currently ships EMPTY, reset to a clean baseline. The floor is set at the reset version, so any config predating it is unmigratable — the accepted clean-slate consequence. The engine, the op vocabulary, and the floor gate are all live; the first future cutover appends the first entry.
runMigrations (charly/migrate.go, driving the engine in charly/migrate_engine.go) is floor-gated, comparing the config's version: stamp against kit.SchemaFloor() and kit.LatestSchemaVersion():
nothing to migrate (already at schema <HEAD>).predates the supported floor … re-author against the current schema) and NO filesystem change. Configs from before the baseline reset fall here.[floor, HEAD) → apply every migration-table step newer than the stamp (in order, via the op-walker / apply: hooks), then re-stamp every versioned file to HEAD (universalStamp).Per-step backups follow the established <file>.bak.<unix-ts> convention.
charly/refs.go auto-runs RunProjectMigrations on a freshly-cloned remote-repo cache so external repos pull through at the latest schema. It skips every touches_host step and leaves the host-deploy path empty, so a remote fetch never mutates the user's per-host state — even the final re-stamp touches only the cache's project files. A remote whose config predates the floor fails the fetch with the same predates-floor error (an old remote is unmigratable — the accepted clean-slate consequence).
The load-time gate gateSchemaVersion (charly/unified.go) is unchanged: LoadUnified parses the merged version: and rejects anything below HEAD (or absent, or non-CalVer):
charly.yml: schema 2026.174.1100 is required (found "4"). Run: charly migrate
A non-CalVer value (a legacy integer, empty, or garbage) parses as "older than every real CalVer", so a stale config trips the gate with a uniform Run: charly migrate hint — the forward-compat trigger that makes a future migration fire. Whether that migrate then SUCCEEDS depends on the floor: a config in [floor, HEAD) migrates; one below the floor is refused (see "How it runs"). Residual-key checks (e.g. kind: deployment, target: host, secret_backend: kdbx) remain as defense-in-depth, but every remediation hint points uniformly at bare charly migrate.
The payoff of the declarative engine: the common case needs zero new Go.
charly/migrations.cue with a version: strictly greater than the current HEAD, expressing the transform as an ops: list (rename_key / delete_key / remap_scalar / move_key).#SchemaVersion in charly/schema/version.cue to that same CalVer.task cue:gen to regenerate charly/spec/version_gen.go.That is the whole common case — no bespoke migrator type, no registry function, no per-migrator Go file. Only a structural reshape the four ops can't express additionally registers ONE goHooks entry (a Go function named by the step's apply: field). Update the HEAD-CalVer fixtures + the repo's own versioned YAML in the same change.
The operator command never changes — it stays charly migrate.
The validated schema is CUE-single-source. The @go()-annotated charly/schema/*.cue defs are the sole source for the Go param structs (generated into charly/spec by task cue:gen); a wire-key change is a CUE edit first, then task cue:gen, then — only if it breaks existing on-disk configs — a migration-table entry here (plus the #SchemaVersion bump above). A pure codegen refactor that leaves every authored wire key untouched needs NO migration entry and NO version: bump. See the /charly-internals:go recipe "How to change the charly.yml schema (CUE is the single source of truth)".
version: AND mints a git tagAny change to the YAML schema or composition format (a key rename, a deleted key, a new key shape) is a hard-cutover that MUST:
#SchemaVersion CalVer — edit charly/schema/version.cue, run task cue:gen, and append the matching entry to charly/migrations.cue. The load-time gate then rejects any not-yet-migrated config with a Run: charly migrate hint, so every reader sees the new format. Raising #SchemaVersion WITHOUT the migration-table entry (or vice-versa) is forbidden — and version: is NEVER set above LatestSchemaVersion() (newer configs hard-fail at load).v<YYYY.DDD.HHMM> from the push moment (see "Per-push release git tags" above). The tag and the version: bump are decoupled: the tag marks the push, version: marks the schema. A schema cutover happens to do BOTH at once, but a content-only push (no schema change) still mints a tag at an unchanged version:.Running charly migrate twice is a no-op: after the first run the config is stamped at HEAD, so the second run hits the floor gate's "at HEAD → nothing to migrate" branch. Every op is itself idempotent (a rename with no matching key does nothing). The migration table's own invariants — every entry a valid #Migration, versions strictly ascending and unique, HEAD == #SchemaVersion — are validated against CUE at process start.
/charly-image:layer — the node-form candy schema migrations produce/charly-image:image — image: entries + charly box build/validate/inspect/charly-core:deploy — the deploy entries a migration may rewrite/charly-local:local-spec — kind: local templates/charly-build:secrets, /charly-build:settings — the credential schema/charly-internals:go — loader internals (LoadUnified, ParseCalVer, the gateSchemaVersion load gate, the charly/migrate_engine.go op-walker)/charly-internals:cutover-policy — why hard-cutover + a single idempotent charly migrate is the required shapetools
Use when authoring or modifying a charly PLUGIN — a candy with a `plugin:` block that contributes Providers (verbs/kinds/deploy-targets/steps/builders/commands), its own CUE schema, builtin (compiled-in) or external (out-of-tree git repo). Covers the unified Provider model, the per-plugin CUE-schema contract (single source → Go params for dev + schema-over-Describe RPC for runtime), the SDK, and the loader.
tools
The CUE data-validation / configuration CLI (cue), pinned to v0.16.1. Use when working with the cue candy, installing the cue binary into a box or onto a target:local dev host, or running the offline schema-vendoring pipeline that feeds charly's egress validation.
tools
CUE EGRESS validation — validating (and, where it adds value, generating) the config files charly WRITES to a system BEFORE the bytes hit disk. MUST be invoked before working on charly/egress.go, the vendored schemas under candy/plugin-egress/egress-schemas/vendor/, the ValidateEgress / registerVendoredEgressKind path, the offline `task cue:vendor` pipeline, or adding an egress schema for any written artifact (cloud-init, k8s manifests, traefik routes, runtime config, install ledger, systemd/quadlet units, ssh_config, libvirt XML).
tools
Kubernetes cluster-probe declarative check verb — the `kube:` check verb (nodes, pods, ingress, storage class, addon health, apply/delete, and arbitrary resource GETs) served out-of-process by the candy/plugin-kube plugin (vendored client-go; no external kubectl required).