mcpbeat Sign in

Conveyor Desktop Packaging Agent Skill

Packaging a JVM desktop app with a config-driven packager whose HOCON config silently ignores unknown keys — which keys bind at the app level versus a per-OS section versus a nested group, command-line key overrides, pinning the packaging JDK, and the environment block that quietly pins PATH. Reach for it when a key you wrote is having no effect on the built installer and nothing in the build log complains.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill conveyor-desktop-packaging

The instruction itself

3 sections, as written by the author

Config-driven desktop packaging

A packager of this class reads one declarative config (a HOCON dialect) describing the app once,

and emits a per-OS installer for every machine you list. A build-tool plugin generates a companion

config file holding the classpath, the version, the entry point and the JDK; your hand-written file

includes it and adds everything the plugin cannot know.

The format fails open: an unknown key is not an error, not a warning, not a note. A key written

at the wrong nesting level is therefore indistinguishable from a key that works — until you open the

built artifact. Read config-fails-open-verify-artifact alongside this.

# adapted — key names real, values generic
app {
  // Binds on the app-level config object. NOT under mac/windows/linux.
  url-schemes = [ "yourapp", "authcb" ]

  machines = [ "windows.amd64", "mac.amd64", "mac.aarch64", "linux.amd64.glibc" ]

  // Per-machine inputs: each installer carries only the native slice it needs.
  mac.aarch64.inputs += { from = "natives/macos-arm64", to = "runtime-natives" }

  // Genuinely per-OS.
  mac.info-plist.LSApplicationCategoryType = "public.app-category.music"
  windows.start-menu.group = "YourApp"
  linux.desktop-file."Desktop Entry" {   // quoted: the key contains a space
    Categories     = "AudioVideo;Audio;Player;"
    StartupWMClass = "YourApp"
  }

  jvm {
    modules  = "ALL-MODULE-PATH"
    options += "-Xmx512m"
  }
}

Traps

The nesting level is part of the key, and the per-OS sections are not a free-for-all. URL-scheme

registration binds on the *app-level* config object; the per-OS objects expose no such key. Writing

mac.url-schemes / windows.url-schemes / linux.url-schemes parses fine and registers **no scheme

at all**, on any platform — silently, so the misplacement persists; in the case this was mined from

it sat inert from the release that first carried the key until moved. Per-OS sections do own genuinely

per-OS keys (the plist entry, the start-menu group, the desktop-file group above), which is exactly

why the wrong placement looks plausible. Tell them apart by building one machine and reading the

generated metadata below, not by intuition.

A nested group's fields must be *inside* the group. Desktop-entry keys written as

desktop-file.Categories land beside the "Desktop Entry" group instead of in it, so they never

reach the generated file — same silent shape as above, different key. Quote any key with a space or

a bracket: "Desktop Entry", "Comment[en]".

**Command-line overrides use -K<dotted.key>=<value>, and list values need brackets plus shell

quoting.** This is how you build one slice of the matrix without editing the config:

conveyor -Kapp.machines=linux.amd64.glibc make linux-app
conveyor '-Kapp.machines=[mac.amd64,mac.aarch64,windows.amd64]' make site

Restricting the machine list is also how you keep a target you deliberately do not ship from being

re-entered as an intermediate step and failing the whole run.

Generate the plugin's config file as its own step, before invoking the packager. Some packagers

support an include form that executes the build tool and parses its stdout as config. Any plugin in

your build that prints a line during the configuration phase then corrupts the parse, with an error

naming your own log text as a config key. Run the config-writing task first, commit to the static

file:

./gradlew :<desktop-module>:writeConveyorConfig --no-configuration-cache
conveyor ... make ...

The packaging JDK comes from your build's Java toolchain, not from the packager. The generated

config carries an include of a JDK definition matching the toolchain the plugin observed. Set the

toolchain explicitly in the desktop module, and set the CI runner's JDK to the same major version —

otherwise you package a runtime you never tested against.

**The generated launcher packs its own arguments into a fixed-size buffer, and a full one fails the

build, loudly, not silently.** Every jvm.options entry plus the classpath and other "constant app

arguments" the packager computes are packed together into one buffer inside the launcher it

generates — reported as 16384 bytes in this project's own config comment recording the incident;

treat the number as version-specific and read whatever your own build prints instead of trusting it.

This is the opposite failure mode from the rest of this skill: nothing here is ignored, the build

simply refuses to finish, with a message naming neither a byte count nor the buffer — quoted from

that same comment: Your JVM arguments and constant app arguments together are too large. A

dependency that ships several per-platform jars can push the classpath alone close enough to the

limit that *any* added option is what finally tips it over — the options themselves are rarely the

real growth. Cut the ones with no functional behavior riding on them first: three flags here existed

only to trim idle heap back to the OS, with nothing in the app depending on them, unlike the

--add-opens module opens a few lines up, which reflection at runtime genuinely needs — -Xmx512m

itself is only a footprint cap, per that same comment, so losing the trio costs a higher idle RSS and

nothing else. Leave the removed lines in a comment, with the reason — the next person adding an

option here needs to know the margin is already thin:

// adapted — own summary of a longer in-file comment; the three removed lines are verbatim
// removed to fit under the launcher's argument buffer, NOT because they stopped being right:
// options += "-XX:MinHeapFreeRatio=20"
// options += "-XX:MaxHeapFreeRatio=40"
// options += "-XX:G1PeriodicGCInterval=60000"

Declaring an environment block on macOS pins PATH to the bare system directories

(/usr/bin:/bin:/usr/sbin:/sbin), because the launch services layer applies it instead of inheriting

the user's environment. Before adding one for an allocator or locale setting, audit every place the

app spawns a subprocess: anything resolved by bare name outside those four directories stops being

found, and only when launched from the desktop — the same block never applies to a direct shell

launch, so it looks intermittent and is absent in exactly the configuration you debug in.

**Every scheme, association and category you declare here must also survive the layers after the

packager.** If you re-wrap the packager's output (a directory tree into a single-file portable

bundle, for instance), the wrapper's own generated metadata is what reaches users. See

desktop-deep-link-plumbing.

Dropping an architecture is a native-dependency decision, not a config decision. A machine you

list must have every native your dependency graph loads — the database driver and the media backend

are the usual gaps. Audit the natives per architecture before adding the machine; one missing slice

fails at first use, not at package time.

Verifying it

Never trust the config for a claim about the artifact:

conveyor -Kapp.machines=mac.aarch64 make mac-app     # or your smallest machine
# then grep the produced tree for the feature's fingerprint, e.g.
grep -R 'CFBundleURLSchemes' -A4 output/*.app/Contents/Info.plist
grep -R 'MimeType\|Categories' output/*.desktop

Do this once per config key that has no other observable effect, and again whenever you move a key.

Wire the same grep into CI as a hard assertion for the keys you cannot afford to lose.

How to use it

Copy the folder

Take maxrave-dev/conveyor-desktop-packaging from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.