CommandLib - A Feature-Rich Minecraft Command API for Spigot, Paper, Velocity, Forge, NeoForge and Fabric
CommandLib is an advanced, type-safe Command API for Spigot, Paper, Velocity, Forge, NeoForge and Fabric developers. It abstracts and enhances the Minecraft 1.16.5+ command APIs, simplifying command implementation. With features like customizable argument suggestions, seamless validation, improved usability, and extensibility, CommandLib empowers developers to build better commands with ease.
| Platform | Artifact | Tested Versions | Notes |
|---|---|---|---|
| Spigot | spigot |
1.16.5, 1.17.1, 1.18–1.18.2, 1.19–1.19.4, 1.20–1.20.6, 1.21–1.21.11, 26.1–26.3 |
Each release in the range is tested on Spigot, and 1.16.5–1.21.1 also on Paper. Requires Java 11+. |
| Paper | paper |
1.21, 1.21.3–1.21.11, 26.1.1–26.3 |
Uses Paper official command/lifecycle API. Requires Java 21+. Use spigot on Paper 1.20.6 and earlier. |
| Forge | forge-<minecraft-version> |
1.16.5, 1.18.2, 1.19.2, 1.20.1 |
Use the artifact that matches the target Minecraft version. |
| NeoForge | neoforge-<minecraft-version> |
1.21.1, 1.21.4 |
Use the artifact that matches the target Minecraft version. Requires Java 21+. |
| Fabric | fabric-<minecraft-version> |
1.20.1, 1.21.1, 1.21.4 (1.21.4 built and unit-tested; not yet run on a real server) |
Use the artifact that matches the target Minecraft version. Requires Fabric API. |
| Mohist | spigot |
1.16.5, 1.20.1 |
Works on Mohist since it's compatible with Spigot. |
| Folia | paper |
26.3 (smoke test: registration, player commands, async threads, suggestions) |
Detected at runtime; async commands return to the entity, region or global region thread. Declare folia-supported: true. See Folia. |
| Velocity | velocity |
3.4.0 (smoke test with a client through the proxy: command tree, execution, failures, permission, suggestions, async), 4.2.0 (manual proxy run, no client) |
Built against Velocity API 3.4.0. Requires Java 17+. Proxy arguments only; see Velocity. |
- Type-Safe Arguments Handling
Use arguments in a type-safe manner directly within your code, reducing potential runtime errors and improving maintainability. - No Need for Brigadier and NMS Dependency
Simplify your setup and ensure compatibility across multiple Minecraft versions. - Powerful Suggestion Generation
Automatically generate argument suggestions with customizable options for enhanced flexibility. - Typed Command Options
Define command options such as-f,--force, or--limit 10and read them from the command context in a type-safe way. - Automatic Usage and Help Generation
Generate usage and help messages from the command tree, including arguments, subcommands, options, descriptions, and examples, and replace how the help, the usage lines and the errors look with formatters (docs). - Seamless Integration with the
/executecommand
Allow your commands to be executed seamlessly from the/executecommand, just like built-in commands. - Documentation Exports and Diagnostics
Export the commands and every generated permission node as Markdown or JSON, and inspect the registrations and the platform at runtime (docs). - Middleware and Async Execution
Wrap executions with typed middlewares, and run heavy work off the server thread withexecuteAsync, coming back to it for messages and the platform API. - Kotlin DSL
Build the same commands withcommand("heal") { ... }blocks, typed argument references, options as delegated properties, andsuspendexecutors and suggestion providers (docs). - Annotated Commands
Write a command as a class with@Command,@Subcommand,@Flagand@Optionmethods; it builds the same command as the fluent API, with the same help, permissions and tests (docs). - Localized Messages
CommandLib's own messages are captions sent in each sender's locale, in English and Japanese out of the box, and a plugin can replace them per key and locale or load its own properties files (docs). - Sender Mapping and Context Values
Map the sender to your own type once, attach values from middlewares, and give commands their services; executors, annotated methods and the Kotlin DSL read them by type without casts (docs).
- One command model for Spigot, Paper, Velocity, Forge, NeoForge, and Fabric. The same
Command/argument(...)code compiles against each platform artifact: Spigot from Minecraft 1.16.5 to 26.3, Paper from 1.21, Velocity 3.4+, Forge 1.16.5, 1.18.2, 1.19.2 and 1.20.1, NeoForge 1.21.1 and 1.21.4, and Fabric 1.20.1, 1.21.1 and 1.21.4 (see Supported Versions). - Typed values in the executor.
argument(new PlayerArgument("target"), new IntegerArgument("amount"))hands aPlayerand anIntegerto the lambda: no lookup by name, no casts. - Brigadier-native. Commands are real Brigadier nodes, so they get client-side validation, vanilla selectors,
/executeand data pack function support, and per-player command trees. - Permissions without boilerplate. Every command, subcommand, and argument branch gets a permission node, which
also hides what a sender cannot use. On Spigot, Paper, Forge, NeoForge and Fabric the node has a default. Velocity
has no permission defaults, so a player gets a node other than
DefaultPermission.ALLonly from a permission plugin.visibleWhen(ctx -> !game.isRunning())hides a command or branch by plugin state as well (permissions). - Options, generated help, and test utilities built in.
| CommandLib | CommandAPI | Paper command API | |
|---|---|---|---|
| Platforms | Spigot, Paper, Forge, NeoForge, Fabric, Velocity | Spigot, Paper (others: see its docs) | Paper |
| Executor arguments | typed parameters | args.get("name") + cast |
ctx.getArgument("name", Type.class) |
| Generated permission nodes | every command and branch | manual | manual |
Flags and options (-f, --limit 10) |
built in | — | — |
| Generated usage / help | built in | /help entries |
/help entries |
| Server-free test utilities | spigot-testing, paper-testing, velocity-testing, forge- / neoforge- / fabric-testing |
test toolkit (MockBukkit) | — |
| Chat / NBT arguments | text component, message, NBT compound (SNBT string on Bukkit) | yes | component, signed message; no NBT |
See the comparison for seven use cases written with CommandLib, CommandAPI, Cloud, Lamp, ACF and Paper's command API, including where the others are better, the argument catalog for an argument-by-argument comparison and compatibility for the tested server versions.
- Arguments: built-in arguments, comparison with CommandAPI and Paper, and gaps.
- Suggestions: default, custom, asynchronous, and tooltip suggestions.
- Permissions: node names, defaults per platform, registration, and LuckPerms.
- Errors: what the sender sees when a command fails.
- Help, usage and error formatting: the generated help, examples, and the help, usage and error formatters.
- Localization: captions, the sender's locale, translations, and caption formatting.
- Vanilla integration:
/execute, command blocks, functions, and tags. - Execution: middlewares, async executors, threads, and
/execute storefor async commands. - Annotated commands: commands as annotated classes, and how they map to the fluent API.
- Context values: sender mapping, values from middlewares, and dependencies.
- Testing:
CommandTesterandFakeSenderfor plugin and mod tests. - Kotlin DSL:
command { }blocks, delegated options, and coroutines. - Java builder:
Command.builder(name)chains, and branches of eight or more arguments. - Documentation exports and diagnostics: Markdown / JSON of the commands and permission nodes, and a runtime report of the platform and the registrations.
- Troubleshooting.
- Javadoc: where to browse the Javadoc of each module.
- Comparison: seven use cases in CommandLib, CommandAPI, Cloud, Lamp, ACF and Paper, with the
CommandLib side compiled and tested in
examples. - Migrating from CommandAPI.
- Migrating from Cloud.
- Migrating from Lamp.
- Migrating from ACF.
- Compatibility: tested platforms and versions.
- Packaging: shading and relocating CommandLib per platform, Jar-in-Jar, and the artifacts.
- Sample projects: buildable plugins and mods for each platform.
To ensure stability, we recommend replacing latest.release with a specific version such as 0.18.0.
You can find the latest version on the releases page.
Shade CommandLib into your plugin or mod and relocate dev.marulabs.commandlib on every platform, as below. See
packaging for the reasons, Maven Shade, Kotlin, and why Jar-in-Jar and Fabric's include() are
not recommended while CommandLib is 0.x. The sample projects build this way.
Spigot (Groovy DSL)
plugins {
id "com.gradleup.shadow" version "9.4.1"
}
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation "com.github.Maru32768.CommandLib:spigot:latest.release"
}
shadowJar {
archiveFileName = "${rootProject.name}-${project.version}.jar"
// Avoid package conflicts
relocate "dev.marulabs.commandlib", "${project.group}.${project.name.toLowerCase()}.commandlib"
}
tasks.build.dependsOn tasks.shadowJarSpigot (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.Maru32768.CommandLib:spigot:latest.release")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("${rootProject.name}-${project.version}.jar")
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
}
tasks.named("build") { dependsOn(tasks.named("shadowJar")) }Paper (Groovy DSL)
plugins {
id "com.gradleup.shadow" version "9.4.1"
}
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation "com.github.Maru32768.CommandLib:paper:latest.release"
}
shadowJar {
archiveFileName = "${rootProject.name}-${project.version}.jar"
// Avoid package conflicts
relocate "dev.marulabs.commandlib", "${project.group}.${project.name.toLowerCase()}.commandlib"
}
tasks.build.dependsOn tasks.shadowJarNote: The Paper artifact targets Paper 1.21.0+.
Paper (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.Maru32768.CommandLib:paper:latest.release")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("${rootProject.name}-${project.version}.jar")
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
}
tasks.named("build") { dependsOn(tasks.named("shadowJar")) }Note: The Paper artifact targets Paper 1.21.0+.
Forge (Groovy DSL)
plugins {
id "com.gradleup.shadow" version "9.4.1"
}
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation "com.github.Maru32768.CommandLib:forge-1.20.1:latest.release"
}
shadowJar {
dependencies {
// The platform artifact and the common artifact it depends on
include(dependency("com.github.Maru32768.CommandLib:.*:.*"))
}
// Avoid package conflicts
relocate "dev.marulabs.commandlib", "${project.group}.${project.name.toLowerCase()}.commandlib"
finalizedBy("reobfShadowJar")
}
reobf {
shadowJar {
}
}Forge (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.Maru32768.CommandLib:forge-1.20.1:latest.release")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
tasks.named<ShadowJar>("shadowJar") {
dependencies {
// The platform artifact and the common artifact it depends on
include(dependency("com.github.Maru32768.CommandLib:.*:.*"))
}
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
finalizedBy("reobfShadowJar")
}
@Suppress("UNCHECKED_CAST")
(extensions.getByName("reobf") as NamedDomainObjectContainer<Any>).create("shadowJar")NeoForge (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.Maru32768.CommandLib:neoforge-1.21.1:latest.release")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("${rootProject.name}-${project.version}.jar")
dependencies {
// The platform artifact and the common artifact it depends on
include(dependency("com.github.Maru32768.CommandLib:.*:.*"))
}
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
}Note: NeoForge runs on Mojang names, so the shadow jar does not need a reobf step.
Fabric (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import net.fabricmc.loom.task.RemapJarTask
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
// Fabric API is required.
modImplementation("com.github.Maru32768.CommandLib:fabric-1.21.1:latest.release")
// Optional: lets permission mods such as LuckPerms answer CommandLib's permission checks.
modImplementation("me.lucko:fabric-permissions-api:0.3.3")
include("me.lucko:fabric-permissions-api:0.3.3")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
val shadowJar = tasks.named<ShadowJar>("shadowJar") {
archiveClassifier.set("dev-shadow")
dependencies {
// The platform artifact and the common artifact it depends on. Loom puts the remapped one in the group
// remapped.<group>.
include { it.moduleGroup.endsWith("com.github.Maru32768.CommandLib") }
}
// Keep your fabric.mod.json, not CommandLib's
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
}
// Remap your mod and the relocated CommandLib to intermediary together
tasks.named<RemapJarTask>("remapJar") {
inputFile.set(shadowJar.flatMap { it.archiveFile })
}public final class MyMod implements ModInitializer {
@Override
public void onInitialize() {
CommandLib.register("mymod.command", new SpawnCommand());
}
}Note: Declare
fabric-command-api-v2andfabric-lifecycle-events-v1(orfabric-api) in yourfabric.mod.json. The jar is also a Fabric mod (commandlib) thatinclude()can nest, but only the newest nested copy is loaded; see packaging. Without fabric-permissions-api, permission checks use each node's default and the vanilla permission level.
Velocity (Kotlin DSL)
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow") version "9.4.1"
}
repositories {
maven { url = uri("https://repo.papermc.io/repository/maven-public/") }
maven { url = uri("https://jitpack.io") }
}
dependencies {
compileOnly("com.velocitypowered:velocity-api:3.4.0")
annotationProcessor("com.velocitypowered:velocity-api:3.4.0")
implementation("com.github.Maru32768.CommandLib:velocity:latest.release")
}
val projectGroup = project.group.toString()
val projectNameLower = project.name.lowercase()
tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("${rootProject.name}-${project.version}.jar")
// Avoid package conflicts
relocate("dev.marulabs.commandlib", "$projectGroup.$projectNameLower.commandlib")
}
tasks.named("build") { dependsOn(tasks.named("shadowJar")) }spigot-kotlin, paper-kotlin, and velocity-kotlin add a Kotlin DSL on top of the same command model. Use one of
them in place of the platform artifact, which they bring along:
dependencies {
implementation("com.github.Maru32768.CommandLib:spigot-kotlin:latest.release")
// Only for executeSuspend and the other coroutine functions.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}val heal = command("heal") {
description("Heal players")
val silent by flag("silent", 's')
argument(IntegerArgument("amount"), PlayerArgument("targets").variadic()) { amount, targets ->
execute { ctx ->
targets.get().forEach { it.health = minOf(it.health + amount.get(), 20.0) }
if (!silent) ctx.sendSuccess("Healed ${targets.get().size} players")
}
}
subcommand("stats") {
executeSuspend { ctx ->
val count = withContext(ctx.asyncDispatcher) { database.countHeals() }
ctx.sendSuccess("$count heals so far")
}
}
}
CommandLib.register(this, heal)The block's receiver is the Command, so every fluent method (permission, requirePlayer(), cooldown, ...) works
in it, and the tree, permissions, and help are the same as the fluent API's. See Kotlin DSL.
Command.builder(name) configures a command in one chain, without a subclass or an initializer block. build()
returns the same Command as the fluent API, with the same tree, help, and permissions:
Command heal = Command.builder("heal")
.description("Heal a player")
.permission(DefaultPermission.OP)
.addAliases("h")
.configure(Command::requirePlayer)
.argument(new PlayerArgument("target"), new IntegerArgument("amount"),
branch -> branch.execute((target, amount, ctx) -> {
target.setHealth(Math.min(20, target.getHealth() + amount));
}))
.addChild(Command.builder("all")
.execute(ctx -> Bukkit.getOnlinePlayers().forEach(p -> p.setHealth(20))))
.build();
CommandLib.register(this, heal);The builder has the Command methods under the same names; argument(...) and require(...) take a callback that
configures the branch, and configure(...) reaches platform methods such as requirePlayer(). A branch of eight or
more arguments, which the typed callbacks do not cover, reads each value with ctx.getArgument(argument):
argument(x1, y1, z1, x2, y2, z2, world, name).execute(ctx -> ctx.getArgument(world)...). See
Java builder.
CommandLib provides command-level test utilities for downstream plugins.
dependencies {
// Pick the one line that matches your platform artifact.
testImplementation("com.github.Maru32768.CommandLib:common-testing:latest.release")
testImplementation("com.github.Maru32768.CommandLib:spigot-testing:latest.release")
testImplementation("com.github.Maru32768.CommandLib:paper-testing:latest.release")
testImplementation("com.github.Maru32768.CommandLib:velocity-testing:latest.release")
testImplementation("com.github.Maru32768.CommandLib:forge-testing-1.20.1:latest.release")
testImplementation("com.github.Maru32768.CommandLib:neoforge-testing-1.21.1:latest.release")
testImplementation("com.github.Maru32768.CommandLib:fabric-testing-1.21.1:latest.release")
}Use common-testing for commands built against the platform-neutral common
API, spigot-testing for commands built against the spigot artifact,
paper-testing for commands built against the paper artifact,
velocity-testing for commands built against the velocity artifact, and
forge-testing-<minecraft-version> / neoforge-testing-<minecraft-version> /
fabric-testing-<minecraft-version> for commands built against the matching
forge-<minecraft-version> / neoforge-<minecraft-version> /
fabric-<minecraft-version> artifact. They provide
CommandTester and FakeSender so commands can be executed without a running
Minecraft server. Use one testing artifact per test module; these artifacts
share package-level helper names.
class Test {
void test() {
FakeSender sender = FakeSender.player("Steve");
try (CommandTester tester = new CommandTester(MyCommand::new, "myplugin.command")) {
tester.execute("mycmd arg", sender);
}
assertThat(sender.getSentMessageTexts()).containsExactly("expected message");
}
}Pass the command as a Supplier (MyCommand::new) so that platform argument types are created while the tester's
mocks are active. See testing for fake players, permissions, suggestions, and what each artifact
mocks.
paper-testing stubs Paper ArgumentTypes, selector resolvers, and several
registry-backed argument paths for command-level tests. Paper lifecycle
registration and real server registry behavior are covered by integration tests.
forge-testing, neoforge-testing, and fabric-testing bootstrap Minecraft's registries, so vanilla
argument types such as items, blocks, effects, and enchantments parse for real. The server is a Mockito mock:
fake players added with CommandTester#withPlayer (and the executing player)
are online, so player, entity, game profile, and UUID arguments resolve them by
name. Levels added with CommandTester#withLevel are the server's worlds; stub other server state, such as the
scoreboard or the advancements, with CommandTester#withServer.
The velocity artifact registers the same Command model on a Velocity proxy as BrigadierCommands. Register from
the proxy initialize event with the plugin instance and the ProxyServer:
@Plugin(id = "myproxy", name = "MyProxy", version = "1.0.0")
public final class MyProxyPlugin {
private final ProxyServer proxy;
@Inject
public MyProxyPlugin(ProxyServer proxy) {
this.proxy = proxy;
}
@Subscribe
public void onProxyInitialize(ProxyInitializeEvent event) {
Command send = new Command("send");
send.argument(new PlayerArgument("player"), new ServerArgument("server")).execute((player, server, ctx) -> {
// 'player' is a com.velocitypowered.api.proxy.Player, 'server' a RegisteredServer
player.createConnectionRequest(server)
.fireAndForget();
ctx.sendSuccess("Sending " + player.getUsername() + " to " + server.getServerInfo()
.getName());
});
// Permission nodes are myproxy.command.send and myproxy.command.send.player.server
CommandLib.register(this, proxy, send);
}
}The client only knows Brigadier's own argument types through a proxy, so the Velocity artifact has the primitive,
string, duration, enum, literal, and object arguments, plus PlayerArgument (an online player by name) and
ServerArgument (a registered server by name), both suggested by the proxy. There are no world, entity, or location
arguments. The proxy has no operators, so a node whose default is not DefaultPermission.ALL needs a permission plugin
such as LuckPerms to grant it to players; the console has every permission. Players who are online see new commands
the next time their backend server sends the command tree, such as on a server switch.
Defining Commands
public final class TestPlugin extends JavaPlugin {
public void onEnable() {
Command message = new Command("message");
message.argument(new PlayerArgument("target"), new StringArgument("message")).execute((target, messageText, ctx) -> {
// 'target' is inferred as 'org.bukkit.entity.Player'. No need to cast.
target.sendMessage(messageText);
});
CommandLib.register(this, message);
}
}// Also you can define commands by extending Command
public final class MessageCommand extends Command {
public MessageCommand() {
super("message");
argument(new PlayerArgument("target"), new StringArgument("message")).execute((target, message, ctx) -> {
target.sendMessage(message);
});
}
}
public final class TestPlugin extends JavaPlugin {
public void onEnable() {
CommandLib.register(this, new MessageCommand());
}
}Appending Subcommands
public final class TestPlugin extends JavaPlugin {
public void onEnable() {
Command game = new Command("game");
Command start = new Command("start");
start.execute(ctx -> {
// Starts game
});
Command stop = new Command("stop");
stop.execute(ctx -> {
// Stops game
});
game.addChildren(start, stop);
CommandLib.register(this, game);
}
}Appending Subcommands After Arguments
CommandLib also supports Brigadier-style command trees where an argument is followed by a literal subcommand.
Use this for vanilla-like command shapes such as /config <key> get or /config <key> set <value>.
For most plugin commands, prefer the simpler literal-first shape, such as /config get <key> and
/config set <key> <value>. Argument-first trees are useful when the target naturally comes before the action, or when
you need to match an existing command syntax.
public final class ConfigCommand extends Command {
public ConfigCommand() {
super("config");
argument(new StringArgument("key")).description("Select a config key")
.child(key -> new Command("get") {{
execute(ctx -> {
String configKey = key.get();
// Get config value
});
}})
.child(key -> new Command("set") {{
argument(new StringArgument("value")).execute((value, ctx) -> {
String configKey = key.get();
// Set config value
});
}});
}
}The child(...) factory receives an ArgRef<T> for each parent argument. Call key.get() inside the child's executor
or suggestion providers to read the parsed value. In code that runs after the callback returns, such as a scheduled task,
read the value first or use key.get(ctx).
Valid inputs:
/config difficulty get
/config difficulty set hard
Defining Command Options
Command options can be placed immediately after the command name and before regular arguments. The full reference is docs/options.md.
public final class ScanCommand extends Command {
public ScanCommand() {
super("scan");
CommandOption<Boolean, CommandContext> force = option(Options.flag("force", 'f')
.description("Run even if safety checks fail."));
CommandOption<Boolean, CommandContext> verbose = option(Options.flag("verbose", 'v')
.description("Send detailed progress messages."));
CommandOption<Integer, CommandContext> limit = option(Options.integer("limit", 'n', 10, 1, 100)
.description("Maximum number of targets."));
argument(new PlayerArgument("target")).execute((target, ctx) -> {
boolean isForce = ctx.getOption(force);
boolean isVerbose = ctx.getOption(verbose);
int maxCount = ctx.getOption(limit);
boolean limitWasSpecified = ctx.hasOption(limit);
// Do something
});
}
}Valid inputs:
/scan Steve
/scan -f Steve
/scan --force Steve
/scan -f -v Steve
/scan -fv Steve
/scan -n 20 Steve
/scan --limit 20 Steve
/scan -fv -n 20 Steve
Any argument can be an option's value, with its type, suggestions and validation:
option(Options.of("world", 'w', new WorldArgument("world"))) reads --world world_nether as a World. An option
can need its own permission node: Options.flag("silent", 's').permission(DefaultPermission.OP) hides -s from
senders without <command node>.option.silent (permissions).
Options must appear before regular arguments. This is invalid:
/scan Steve -f
unless the command calls optionsAnywhere(), which lets its options follow each argument too
(options).
For subcommands, options belong to the most specific child command and are placed after that child command name:
public final class GameCommand extends Command {
public GameCommand() {
super("game");
addChildren(new Command("start") {{
CommandOption<Boolean, CommandContext> force = option(Options.flag("force", 'f'));
argument(new StringArgument("arena")).execute((arena, ctx) -> {
boolean isForce = ctx.getOption(force);
// Starts game
});
}});
}
}Valid:
/game start -f arena1
Invalid:
/game -f start arena1
Value options use the separated form, such as --limit 20 or -n 20. Forms like --limit=20 and -n20 are not
supported.
Option descriptions are optional. When present, they are shown in the command help message.
Use ctx.hasOption(option) when you need to distinguish an explicitly specified option from its default value.
Options can also depend on other options:
public final class ExportCommand extends Command {
public ExportCommand() {
super("export");
CommandOption<Boolean, CommandContext> force = option(Options.flag("force", 'f')
.description("Overwrite existing files."));
CommandOption<String, CommandContext> reason = option(Options.string("reason", 'r', "")
.description("Reason for forcing.")
.requires(force));
CommandOption<String, CommandContext> mode = option(Options.string("mode", 'm', "normal"));
CommandOption<Integer, CommandContext> threads = option(Options.integer("threads", 't', 1, 1, 16)
.requires(mode, "parallel"));
argument(new StringArgument("target")).execute((target, ctx) -> {
// --reason can be used only with --force.
// --threads can be used only when ctx.getOption(mode) is parallel.
});
}
}Valid:
/export -f -r cleanup data
/export -t 4 -m parallel data
Invalid:
/export -r cleanup data
/export -m normal -t 4 data
requires(otherOption) checks whether the other option was explicitly specified. requires(otherOption, value) checks
the value returned by ctx.getOption(otherOption), so the other option's default value can also satisfy the condition.
Options can also have aliases, be required, be given more than once, and exclude each other:
public final class TagCommand extends Command {
public TagCommand() {
super("tag");
CommandOption<Boolean, CommandContext> force = option(Options.flag("force", 'f')
.aliases("yes"));
CommandOption<Integer, CommandContext> verbose = option(Options.count("verbose", 'v', 3));
CommandOption<List<String>, CommandContext> tags = option(Options.string("tag", 't', "")
.repeatable(5)
.required());
CommandOption<Boolean, CommandContext> json = option(Options.flag("json", 'j'));
CommandOption<Boolean, CommandContext> yaml = option(Options.flag("yaml", 'y'));
mutuallyExclusive(json, yaml);
argument(new PlayerArgument("target")).execute((target, ctx) -> {
boolean isForce = ctx.getOption(force); // --force or --yes
int verbosity = ctx.getOption(verbose); // 2 for -vv
List<String> tagList = ctx.getOption(tags); // [red, blue] for -t red --tag blue
});
}
}/tag --yes -vv -t red --tag blue -j Steve
/tag Steve fails with Missing required option --tag., and /tag -t red -j -y Steve is rejected because --json
and --yaml are mutually exclusive. See docs/options.md for every option kind, the errors, the help
output and the limits.
Suggesting Block Materials
public final class TestPlugin extends JavaPlugin {
public void onEnable() {
CommandLib.register(this, new Command("test") {{
argument(new EnumArgument<>("block", Material.class).validator(Material::isBlock)).execute((m, ctx) -> {
// Do something
});
}});
}
}Defining Optional and Variadic Arguments
optional(defaultValue) lets the sender omit an argument, and the callback receives the default. Pass an extractor
instead to compute the default, such as Command.playerRequirer() to target the sender. variadic() takes an argument
one or more times and gives the values as a List; it accepts up to 16 values, or the count given to
variadic(maxCount). Make it optional with variadic().optional(List.of()).
public final class TestPlugin extends JavaPlugin {
public void onEnable() {
CommandLib.register(this, new Command("give") {{
// /give <item> [count]
argument(new StringArgument("item"), new IntegerArgument("count").optional(1)).execute((item, count, ctx) -> {
// count is 1 when omitted
});
}}, new Command("heal") {{
// /heal [target]: heals the sender when omitted
argument(new PlayerArgument("target").optional(Command.playerRequirer())).execute((target, ctx) -> {
target.setHealth(20);
});
}}, new Command("kick") {{
// /kick <reason> <targets...>
argument(new StringArgument("reason"), new PlayerArgument("targets").variadic()).execute((reason, targets, ctx) -> {
targets.forEach(x -> x.kickPlayer(reason));
});
}});
}
}Only the last arguments of a branch can be optional, only the last one can be variadic, and such a branch cannot have
child commands; registering the command fails otherwise. A branch whose arguments are all optional also runs for the
bare command, so the command cannot have its own execute then. Generated help shows [count] and <targets...>.
Configure each value (validator, suggestions) before variadic() and the whole list after it.
ctx.sendMessage, sendSuccess (green), sendWarn (yellow), and sendFailure (red) accept a String, a native
component, or a RichText. RichText is an immutable, platform-independent message that works the same on Spigot,
Paper, Velocity, and Forge / NeoForge / Fabric, so code shared between platforms can send colors, decorations, hover text, and click
actions:
import dev.marulabs.commandlib.util.text.ClickAction;
import dev.marulabs.commandlib.util.text.RichText;
public final class DifficultyCommand extends Command {
public DifficultyCommand() {
super("difficulty");
execute(ctx -> {
ctx.sendMessage(RichText.text("difficulty: ")
.append(RichText.text("hard")
.color(0xFF5555)
.bold()
.hover(RichText.text("Click to change"))
.click(ClickAction.suggestCommand("/difficulty "))));
ctx.sendFailure(RichText.translatable("commands.give.failed.toomanyitems", RichText.text("64")));
});
}
}sendSuccess / sendWarn / sendFailure only color a message that has no color of its own and never change the value
or component passed in. RichTexts.toBungee (spigot), RichTexts.toAdventure (paper, velocity), and RichTexts.toVanilla
(Forge / NeoForge / Fabric) convert a RichText to the native component. For Paper-only plugins, Adventure and MiniMessage can
be used directly: ctx.sendMessage(MiniMessage.miniMessage().deserialize("<red>Hello")).
CommandLib's own messages, such as "You do not have permission to execute this command." or the help headings, are
captions sent in the sender's locale, with English and Japanese built in. Replace them, and translate your own, with
Captions:
public static final Caption HOME_SET = Caption.of("myplugin.home.set", "Home {name} set.");
Captions captions = Captions.create()
.set(Locale.JAPANESE, Caption.PERMISSION_DENIED, "それはできません。")
// lang/messages_ja.properties etc. in the plugin jar, read as UTF-8.
.bundle("lang/messages", getClass().getClassLoader());
public final class HomeCommand extends Command {
public HomeCommand(Captions captions) {
super("home");
captions(captions);
argument(new StringArgument("name")).execute((name, ctx) -> ctx.sendSuccess(HOME_SET, Map.of("name", name)));
}
}See localization for the lookup order, the keys, and Captions#sender to format every caption.
sendSuccess sends command feedback the way vanilla commands do: a player does not get it while the
sendCommandFeedback gamerule is off, and a silent source such as a function gets nothing. sendSuccess(message, true)
also broadcasts it to the other operators in gray italics and to the server log when logAdminCommands is on, like
/gamemode. sendMessage, sendWarn, and sendFailure always reach the sender. The Velocity proxy has no gamerules
or operators, so there sendSuccess always reaches the sender and ignores broadcastToOps. A source without vanilla
feedback, such as a test sender, gets sendSuccess as a plain message.
sendSuccessFor words the feedback of a command on a selector the way vanilla does, naming the target when there is one
and counting them otherwise:
argument(new PlayersArgument("targets")).execute((targets, ctx) -> {
targets.forEach(x -> x.setHealth(20));
// "Healed Steve" or "Healed 3 players"
ctx.sendSuccessFor(targets, x -> "Healed " + x.getName(), count -> "Healed " + count + " players", true);
});ctx.setResult(int) sets the value that /execute store result stores (1 unless set). A command fails, storing 0 for
both store result and store success, when its executor throws CommandPrerequisiteException, its preprocess returns
false, or a permission, argument, or prerequisite check fails. Its failure messages then reach the sender the way a
vanilla command's error does, so a function or each target of /execute as @a does not get them.
public final class CountCommand extends Command {
public CountCommand() {
super("count");
argument(new PlayersArgument("targets")).execute((targets, ctx) -> {
if (targets.isEmpty()) {
throw ctx.fail("No player was found");
}
ctx.sendSuccess("Counted " + targets.size() + " players", true);
ctx.setResult(targets.size());
});
}
}/execute store result score @s count run count @a then stores the number of players. CommandTester#execute
returns the result, and 0 for a failure, whose messages it sends to the FakeSender. See
errors for each failure.
confirm() makes a command ask before it runs, and cooldown(Duration) keeps a sender from running it again too soon:
public final class ResetCommand extends Command {
public ResetCommand() {
super("reset");
// The first /reset only asks; running exactly the same input again within 10 seconds runs it.
confirm();
// Players wait 5 minutes between two resets.
cooldown(Duration.ofMinutes(5));
execute(ctx -> ctx.sendSuccess("Reset."));
}
}The first execution sends "Run the command again within 10s to confirm. [Confirm]" (a
caption, in the sender's language), where [Confirm] runs the same
command when clicked. Each sender has one pending confirmation: another input, or the same one after the timeout, asks
again. A sender on cooldown gets "You must wait 4m 59s before using this command again." Both are checked after the
permissions, the arguments and the prerequisites, in this order: the registration's CommandDefaults middlewares, the
cooldown, the confirmation, the other middlewares and preprocesses. The cooldown starts only when all of them pass, so a sender on cooldown is not asked to confirm and an unconfirmed
execution does not start the cooldown. Both messages make the command fail like an error, see
errors.
Confirmation and Cooldown change the timeout, the messages, the clock, and the permission that skips the cooldown:
confirm(Confirmation.within(Duration.ofSeconds(30))
.message((ctx, command) -> ctx.sendWarn("Type " + command + " again to delete everything.")));
cooldown(Cooldown.of(Duration.ofSeconds(30))
.bypassPermission("myplugin.cooldown.bypass")
.message((ctx, remaining) -> ctx.sendFailure("Wait " + remaining.toSeconds() + " more seconds.")));- The cooldown is kept per unique id, so it applies to players and entities. The console, remote consoles and command blocks have no unique id and are never on cooldown. Confirmations are kept per unique id too, and per sender type and name for senders without one, so the console has to confirm as well.
- Like a middleware, both apply to the command's argument branches and to child commands that set none of their own,
unless the child calls
disableParentMiddleware(). Children that inherit a cooldown share it, as do commands given the sameCooldowninstance. - Keep the
Cooldowninstance to query or end a sender's cooldown, for example from an admin command:cooldown.remaining(player.getUniqueId())is the time left (zero when not on cooldown),cooldown.remaining(ctx)the time left for the sender of a context,reset(uuid)ends one sender's cooldown andresetAll()every one. - The state lives in memory and is lost when the server restarts.
- On Forge / NeoForge 1.18+ a permission handler cannot grant the bypass permission: only players with permission level 4 skip the cooldown there (permissions).
- In tests, pass a
Clockwithclock(...)instead of waiting for the time to pass.
A middleware wraps the execution of a command, its argument branches and its child commands. It runs after the
permission, argument and prerequisite checks, and returns the stage next.proceed() gives, which completes when the
executor is done:
public final class AdminCommand extends Command {
public AdminCommand(Database database) {
super("admin");
addMiddleware((ctx, next) -> {
if (Maintenance.isActive()) {
throw new CommandPrerequisiteException("The server is in maintenance.");
}
return next.proceed()
.whenComplete((ignored, e) -> Bukkit.getLogger().info(ctx.getSender().getName() + " ran /admin"));
});
addChildren(new ReloadCommand(), new StatsCommand(database));
}
}CommandDefaults applies middlewares and context value providers to every command of a registration. Pass it to
CommandLib.register(this, defaults, commands) on Spigot and Paper, register(this, proxy, defaults, commands) on
Velocity and register(permissionPrefix, defaults, commands) on Forge, NeoForge and Fabric, where commands is a
Collection, and to the testers' builder as defaults(...). See
execution.
executeAsync takes an executor that returns a CompletionStage. ctx.supplyAsync(...) runs the work off the server
thread and completes back on it, so the follow-up may send messages and use the platform API:
argument(new OfflinePlayerArgument("player")).executeAsync((player, ctx) ->
ctx.supplyAsync(() -> database.loadStats(player.getUniqueId()))
.thenAccept(stats -> ctx.sendSuccess(player.getName() + " has " + stats.kills() + " kills")));Exceptions of the work reach the UncaughtExceptionHandlers as an executor's do. Since vanilla needs the result at
once, an async command still running gives /execute store the result set so far (1 unless set). See
execution for the order of middlewares, exception mapping, threads on each platform, timeouts,
cancellation and testing.
A command provides typed values to its executions, such as the sender as the plugin's own type; middlewares can put more, and every executor, prerequisite and suggestion provider of the subtree reads them by type:
public final class TeamCommand extends Command {
public TeamCommand(Games games, Accounts accounts) {
super("team");
provide(GamePlayer.class, ctx -> games.of(playerRequirer().extract(ctx))); // once per execution
addMiddleware((ctx, next) -> {
ctx.put(Account.class, accounts.load(ctx.getSender()));
return next.proceed();
});
argument(new StringArgument("team")).execute((team, ctx) -> ctx.get(GamePlayer.class).join(team));
}
}The console gets "This command can only be executed by a player." from the provider. Annotated methods take the same
values as parameters (void join(GamePlayer player, String team)) through registerContextValue and
registerDependency, with @Dependency fields. See context values.
A command can also be a class whose methods are annotated. AnnotatedCommands.parse builds an ordinary Command
from it through the fluent API, so help, permission nodes, options and CommandTester tests are the same:
@Command("ban")
@Description("Ban a player")
public final class BanCommand {
@Execute
public void ban(CommandContext ctx,
@Flag(value = "silent", shortName = 's') boolean silent,
Player target,
@Greedy @Default("Banned by an operator") String reason) {
target.kickPlayer(reason);
}
@Subcommand("list")
public void list(CommandContext ctx, @Option("limit") @Range(min = 1, max = 100) @Default("10") int limit) {
}
}
CommandLib.register(this, AnnotatedCommands.parse(new BanCommand()));Parameters map by type to the built-in arguments (Player to PlayerArgument, int to IntegerArgument, ...), a
last List<T> parameter is variadic, @Optional / @Default make arguments optional, and a method returning a
CompletionStage runs as executeAsync. @Subcommand("<team> add") puts a subcommand after an argument, and
@Middleware, @VisibleWhen, @Requires, @MutuallyExclusive, @OptionsAnywhere, @Examples and
@Description(caption = ...) match the fluent settings of the same names. Argument names come from the parameter
names, so compile with -parameters or use @Name. See annotated commands for every annotation, custom types and annotations, and
the fluent equivalent.
The generated help can list examples, and three formatters replace how the help, each usage line and each failure CommandLib reports look. Like captions, they apply to the child commands that set none of their own:
public final class ArenaCommand extends Command {
public ArenaCommand() {
super("arena");
examples("/arena create lobby 16");
// The whole help, from a typed model: path, description, usage lines, options, examples.
helpFormatter((ctx, help) -> help.usages()
.forEach(usage -> ctx.sendMessage("§e" + usage.plainText())));
// Each failure: its kind, its exception, and the default messages.
errorFormatter((ctx, error) -> {
ctx.sendFailure("[Arena] The command failed:");
error.sendDefault();
});
// ...
}
}errorFormatter(ErrorFormatter.SYNTAX_ADVICE) adds the usage line of the argument branch that failed after an argument
error. See help, usage and error formatting.
The server's /help lists each Bukkit command with a short text under the plugin's index, and /help <command> shows
its page. description(...) is not used there; serverHelp sets the text for the server help on its own:
public final class SpawnCommand extends Command {
public SpawnCommand() {
super("spawn");
description(ctx -> "Teleports " + ctx.getSender().getName() + " to the spawn.");
// The short text for the /help lists, and optionally the whole /help spawn page.
serverHelp("Teleports you to the spawn.", "Teleports you to the spawn of the current world.");
}
}Without either, the command is listed as "A command by the <plugin> plugin.". Without the page, /help <command>
shows the short text, the usage and the aliases. Only top-level commands use it, and only Spigot and Paper read it:
Forge, NeoForge, Fabric and Velocity have no server help and ignore it.
CommandLib automatically generates and registers permission nodes for each command, subcommand, and argument branch. Bukkit uses Bukkit permissions. Forge and NeoForge use their permission node APIs. Fabric uses fabric-permissions-api when a mod ships it. Velocity uses the proxy's permission subjects.
Permission nodes are generated from the registration prefix and command path.
For Bukkit, if you use CommandLib.register(plugin, commands...), the prefix defaults to
<plugin name in lowercase>.command. For example, a plugin named MyPlugin generates
myplugin.command.spawn.
Pass a custom prefix to CommandLib.register() when you want to use a different namespace.
Bukkit:
public final class TestPlugin extends JavaPlugin {
@Override
public void onEnable() {
// Generates "myplugin.command.spawn", "myplugin.command.game.start", etc.
CommandLib.register(this, "myplugin.command", new SpawnCommand(), new GameCommand());
}
}Forge:
public final class MyMod {
public MyMod() {
// Generates "mymod.command.spawn", "mymod.command.game.start", etc.
CommandLib.register("mymod.command", new SpawnCommand(), new GameCommand());
}
}The prefix is applied to all commands, subcommands, and argument branches recursively.
To assign a specific permission node to a command instead of the auto-generated one:
public final class SpawnCommand extends Command {
public SpawnCommand() {
super("spawn");
permission("myplugin.admin"); // fixed node, ignores prefix
permission("myplugin.admin", DefaultPermission.NONE); // with default
}
}Control who has the permission by default with CommandLib's DefaultPermission enum. Root commands default to OP.
Subcommands inherit their parent command's default unless you override them.
import dev.marulabs.commandlib.DefaultPermission;
public final class TestCommand extends Command {
public TestCommand() {
super("test");
permission(DefaultPermission.OP); // default - only operators
permission(DefaultPermission.ALL); // everyone
permission(DefaultPermission.NONE); // no one (must be granted explicitly)
}
}Platform-specific overloads are also available when you need them:
public final class TestCommand extends Command {
public TestCommand() {
super("test");
permission(PermissionDefault.FALSE); // Bukkit
permission(DefaultPermissionLevel.NONE); // Forge 1.16.5
}
}Argument branches also inherit the parent command's default permission unless you override the branch. A child command attached under an argument branch inherits that argument branch's default permission.
CommandLib also generates permission nodes for argument branches. With prefix myplugin.command, this command
registers and checks both myplugin.command.config and myplugin.command.config.key:
public final class ConfigCommand extends Command {
public ConfigCommand() {
super("config");
argument(new StringArgument("key")).description("Select a config key")
.execute((key, ctx) -> {
// Runs only when the sender has myplugin.command.config.key
});
}
}Argument branch permissions are checked for command execution, tab completion, and generated help output. This is useful when the base command is visible to many users, but only some users can execute or tab-complete a particular argument route.
To assign a specific permission node instead of the auto-generated one:
public final class ConfigCommand extends Command {
public ConfigCommand() {
super("config");
argument(new StringArgument("key")).permission("myplugin.command.config.key")
.description("Select a config key")
.execute((key, ctx) -> {
// Runs only when the sender has myplugin.command.config.key
});
}
}Use CommandLib's common DefaultPermission enum to control the default for an argument branch. If you omit it, the
argument branch inherits the parent command's default:
import dev.marulabs.commandlib.DefaultPermission;
public final class ConfigCommand extends Command {
public ConfigCommand() {
super("config");
argument(new StringArgument("key")).permission(DefaultPermission.OP, "Access config keys");
}
}You can combine argument branch permissions with argument child commands:
public final class ConfigCommand extends Command {
public ConfigCommand() {
super("config");
argument(new StringArgument("key")).permission("myplugin.command.config.key")
.child(key -> new Command("get") {{
execute(ctx -> {
String configKey = key.get();
// Get config value
});
}})
.child(key -> new Command("set") {{
argument(new StringArgument("value")).execute((value, ctx) -> {
String configKey = key.get();
// Set config value
});
}});
}
}CommandLib delegates permission checks to the platform permission system:
- Bukkit:
CommandSender#hasPermission(permissionNode) - Forge / NeoForge:
PermissionAPI.getPermission(player, permissionNode)(Forge 1.16.5:PermissionAPI.hasPermission(player, permissionNode)) - Fabric:
Permissions.check(player, permissionNode, default)of fabric-permissions-api - Velocity:
PermissionSubject#getPermissionValue(permissionNode); an unset node is granted only when its default isDefaultPermission.ALL
LuckPerms can provide those platform permissions. Configure the same generated or custom permission nodes in LuckPerms;
CommandLib does not need additional LuckPerms-specific integration. On Forge 1.18+ and NeoForge the nodes are in the
commandlib namespace, so grant commandlib.<node> there. See permissions.
Players see only the commands and branches they have permission for, but the server does not resend the command tree
when a permission changes. Call CommandLib.refresh(player) (or CommandLib.refreshAll()) after granting or revoking
one, so the change shows without a rejoin. See refreshing the command tree.
A Claude Code skill is available as the commandlib plugin in this repository's plugin marketplace
(plugins/commandlib/). It loads the CommandLib API from your Gradle source cache and generates or explains code on
demand.
- Add this repository as a plugin marketplace and install the plugin, inside Claude Code:
/plugin marketplace add Maru32768/CommandLib
/plugin install commandlib@commandlib
Or from your shell:
claude plugin marketplace add Maru32768/CommandLib
claude plugin install commandlib@commandlibRun /plugin marketplace update commandlib to pick up newer versions of the skill.
To pin the skill to the CommandLib version your project uses, add the marketplace at that release tag (releases from the one after 0.17.2 onward include the plugin):
/plugin marketplace add Maru32768/CommandLib#<version>
To switch versions, run /plugin marketplace remove commandlib and add it again with another tag.
- Download the CommandLib sources jars into the Gradle cache so the skill has something to read. In IntelliJ:
View > Tool Windows > Gradle > Download Sources. Or with Gradle, add a task that resolves them and run
./gradlew downloadCommandLibSources:
val commandlibSources by configurations.creating { isTransitive = false }
dependencies {
// Your platform artifact and common, at the version you depend on.
commandlibSources("com.github.Maru32768.CommandLib:spigot:<version>:sources")
commandlibSources("com.github.Maru32768.CommandLib:common:<version>:sources")
}
tasks.register("downloadCommandLibSources") {
val files: FileCollection = commandlibSources
doLast { files.files }
}/commandlib:commandlib <request>
/commandlib:commandlib I want a command that sends a message to a player. Args: player and string. Permission: myplugin.message
/commandlib:commandlib プレイヤーにメッセージを送るコマンドを作りたい。引数はプレイヤーと文字列、権限は myplugin.message
Small buildable plugins and mods that show the main features; see the overview for which sample shows what.
- Spigot and Paper: every feature, in Java: optional and variadic arguments,
options, middleware and
executeAsync, confirmation and cooldown, the annotation style, the Java builder, and the diagnostics command. - Paper (Kotlin): the Kotlin DSL.
- Velocity: proxy player and server arguments.
- Forge (1.16.5) and Fabric (1.21.1): the platform-neutral commands in a mod.
Each one shades and relocates CommandLib as described in packaging.
CommandLib is licensed under the MIT License. See LICENSE.
Spigot, Paper, Bukkit, Minecraft, Brigadier, Forge, and other platform APIs or runtimes are separate projects and may be governed by their own license terms. They are not bundled with CommandLib unless explicitly stated otherwise.
See third-party dependencies and distribution policy for dependency licenses, GPL platform integration, shading obligations, and Minecraft/mapping distribution restrictions. The MIT license for CommandLib's original code does not override upstream terms for a combined distribution.