Module Development
LuCLI modules are add-on commands written in CFML. Each module is a component under your LuCLI home that LuCLI loads and runs when you use lucli modules run <name>. You can add custom workflows, linters, generators, or any CLI tool you want—all in CFML, with access to LuCLI’s runtime (working directory, verbosity, timing, output helpers).
This guide is for developers who want to create or change modules: how to scaffold one, how Module.cfc and init() work, how LuCLI turns CLI arguments into CFML arguments, and how to implement subcommands and flags.
What is a LuCLI module?
A module is a CFML component that LuCLI executes as a first-class command. It lives in your LuCLI home:
- Directory:
~/.lucli/modules/<module-name>/ - Entry point:
Module.cfc(typicallycomponent extends="modules.BaseModule") - Metadata: optional
module.jsonandREADME.md
For using modules (running them, shortcuts, arguments), see Running Modules.
Creating a new module
The easiest way to start is with the built‑in template:
lucli modules init my-awesome-module
This will create:
~/.lucli/modules/my-awesome-module/Module.cfc~/.lucli/modules/my-awesome-module/module.json~/.lucli/modules/my-awesome-module/README.md
The generated Module.cfc looks like this (simplified):
component extends="modules.BaseModule" {
function init(
verboseEnabled = false,
timingEnabled = false,
cwd = "",
timer = nullValue()
) {
variables.verbose = arguments.verboseEnabled;
variables.timingEnabled = arguments.timingEnabled;
variables.cwd = arguments.cwd;
variables.timer = arguments.timer ?: {};
return this;
}
function main(string myArgument = "") {
out("Hello from my-awesome-module!");
return "Module executed successfully";
}
}
You can customize both init() and main(); the template is just a starting point.
How modules are executed
When you run a module, LuCLI always ends up calling a function on your Module.cfc—main() by default, or another function when you use subcommands.
At a high level LuCLI:
- Resolves the module directory under
~/.lucli/modules/<name>/. - Loads
Module.cfcasmodules.<name>.Module. - Calls
init()once to pass runtime context (verbosity, timing,cwd, timer helper). - Parses CLI arguments into:
- A
subcommand(defaults tomain), and - A normalized argument collection (positional + named/flag arguments).
- A
- Invokes
modules[subcommand](argumentCollection = argCollection).
In CFML land this is roughly equivalent to:
modules = createObject("component", "modules.<name>.Module").init(
verboseEnabled = verbose,
timingEnabled = timing,
cwd = __cwd,
timer = Timer
);
results = modules[subcommand](argumentCollection = argCollection);
For most module authors, the main work happens in main() and other subcommand functions. init() is primarily how LuCLI passes context into your module.
Arguments: how CLI input maps to CFML
The CLI rules are the same as documented in Running Modules, but here is the condensed developer view.
Subcommand vs. main()
- If the first argument does not contain
=and does not start with-or--, LuCLI treats it as the subcommand name. - Otherwise, the subcommand defaults to
main.
Examples:
# Calls main()
lucli reports
# Calls cleanup() subcommand
lucli reports cleanup
Inside CFML you can implement matching functions:
component extends="modules.BaseModule" {
function main() {
// default path when no subcommand is given
}
function cleanup() {
// runs when user passes `cleanup` as first arg
}
}
Positional arguments
Any argument (after the optional subcommand) that does not contain = is treated as a positional value.
lucli mymodule foo bar baz
These become arg1, arg2, arg3, … in the argument collection; CFML will map them onto your function signature by position:
function main(string arg1, string arg2, string arg3) {
// arg1="foo", arg2="bar", arg3="baz"
}
Named arguments and flags
LuCLI normalizes several CLI forms into normal CFML named arguments.
All of the following end up calling main(required string name, boolean force=false) with the same values:
# key=value
lucli mymodule name=hello force=true
# long flags with =
lucli mymodule --name=hello --force=true
# boolean flags
lucli mymodule --name=hello --force
lucli mymodule --name=hello --no-force
Normalization rules (simplified):
key=value→ argumentkeywith value"value".--key=valueor-k=value→ argumentkeywith value"value".--key→ boolean argumentkeywith valuetrue.--no-key→ boolean argumentkeywith valuefalse.
So this function:
function main(
required string name,
boolean force = false
) {
// name & force populated from CLI
}
will be populated correctly for all of the examples above.
Mixed subcommand, positional, and named
lucli reports generate year=2025 format=csv --force
Results in a call roughly like:
modules.generate(
argumentCollection = {
year = "2025",
format = "csv",
force = true
}
);
The init() contract
LuCLI calls init() on your Module.cfc with four arguments:
function init(
boolean verboseEnabled = false,
boolean timingEnabled = false,
string cwd = "",
any timer
) {
// Your setup here
return this;
}
Parameters
-
verboseEnabled(boolean)truewhen LuCLI was started with--verbose.- You can use this to gate debug/diagnostic output.
-
timingEnabled(boolean)truewhen LuCLI was started with--timing.- Lets you decide whether to emit timing‑related output.
-
cwd(string)- The current working directory when the module was invoked.
- Use this as the base path for resolving relative file paths.
-
timer(struct/component)- A timing helper provided by LuCLI (backed by Java timing utilities).
- Exposes at least
start(label)andstop(label)functions. - You can safely treat it as an object with those two methods.
Recommended pattern
If you extend modules.BaseModule, you usually don’t need to think about init() at all. Focus on implementing main() (and any additional subcommands) and let the base class handle context wiring.
The base implementation already:
function init(
boolean verboseEnabled = false,
boolean timingEnabled = false,
string cwd = "",
any timer
) {
variables.verboseEnabled = arguments.verboseEnabled;
variables.timingEnabled = arguments.timingEnabled;
variables.cwd = arguments.cwd;
variables.timer = arguments.timer ?: {
"start": function(){},
"stop": function(){}
};
return this;
}
So in your own module you can usually omit init() entirely and just rely on:
variables.verboseEnabledvariables.timingEnabledvariables.cwdvariables.timer
If you do override init(), make sure you:
- Keep the same parameters (so LuCLI can still call it), and
- Call
super.init()if you want the base behavior, e.g.:
component extends="modules.BaseModule" {
function init(
boolean verboseEnabled = false,
boolean timingEnabled = false,
string cwd = "",
any timer
) {
super.init(
verboseEnabled = arguments.verboseEnabled,
timingEnabled = arguments.timingEnabled,
cwd = arguments.cwd,
timer = arguments.timer
);
// Your own initialization here
return this;
}
}
Verbose output from modules
modules.BaseModule gives you a convenience helper for verbose logging:
function verbose(any message) {
if (variables.verboseEnabled) {
out(message, "magenta", "italic");
}
}
Usage inside your module:
function main() {
verbose("Starting main() in my-awesome-module");
// ...
}
From the CLI, users enable this with:
lucli --verbose my-awesome-module
lucli --verbose modules run my-awesome-module
When --verbose is present, variables.verboseEnabled is true and verbose() will emit colored debug output.
Timing and performance
If LuCLI is started with --timing, the timingEnabled flag and timer object let your module integrate with LuCLI’s timing output.
Typical module usage:
function main() {
variables.timer.start("my-awesome-module main");
// Do some work …
variables.timer.stop("my-awesome-module main");
}
As long as you have a timer object, you can start and stop timers. Making sure you use the same label for start and stop.
This lets your module’s work show up alongside LuCLI’s own timing measurements.
Working with arguments and subcommands
Modules receive arguments exactly as described in Running Modules, but from a developer perspective you typically:
- Implement
function main()for the default path. - Optionally add additional functions like
function cleanup()orfunction report(). - Declare CFML arguments (
required string year,boolean force=false, etc.).
Example:
component extends="modules.BaseModule" {
function main(required string path, boolean force = false) {
verbose("Cleaning path: " & path);
// …
}
function report(string format = "text") {
if (format == "json") {
out({ status = "ok", cwd = variables.cwd });
} else {
out("Status: ok (cwd=" & variables.cwd & ")");
}
}
}
CLI examples for this module:
# Calls main(path, force=false)
lucli cleaner /tmp/cache
# Calls main(path, force=true)
lucli cleaner /tmp/cache --force
# Calls report(format="json")
lucli cleaner report format=json
Accessing environment and filesystem
modules.BaseModule also provides helpers you can use from any module:
-
getEnv(envKeyName, defaultValue="")- Looks in
server.envandSERVER.system.environmentfor environment variables. - Use this instead of
getEnvironmentVariable()directly for consistency.
- Looks in
-
getAbsolutePath(cwd, path)- Normalizes a (possibly relative)
pathagainst the modulecwd.
- Normalizes a (possibly relative)
Example:
function main() {
var apiKey = getEnv("MY_API_KEY", "");
if (!len(apiKey)) {
err("Missing MY_API_KEY in environment");
return;
}
var target = getAbsolutePath(variables.cwd, "./data/output.json");
verbose("Writing to " & target);
}
Summary
- Use
lucli modules init <name>to scaffold a module under~/.lucli/modules. - Extend
modules.BaseModuleto getout,err,verbose,getEnv, and path helpers. - Let LuCLI manage
init()unless you have special needs; otherwise, preserve its signature. - Use
variables.verboseEnabledandverbose()for extra logging controlled by--verbose. - Use
variables.timingEnabledandvariables.timerto integrate with--timing. - Implement
main()and optional subcommand functions to handle your module’s behavior.