Extending the CLI
It is possible to extend the Compas CLI in projects with custom commands. Giving your collaborators the power to do tasks with a consistent experience of discovery and error handling.
Locations
The Compas CLI checks two places for extra commands: the compas
config file, and the top level scripts
directory.
Files in the scripts
directory can export a cli definition and be upgraded from compas run $name --script-args "--arg 1
to compas $name --arg 1
. This is advisable to do for any script requiring arguments to be passed. Making it consistent to document, but also validate these values.
The config/compas.{js,json}
is loaded by the config loader. And expects the following config object:
{
"cli": {
"commandDirectories": ["./directory/relative/to/project/root", "./and/another"]
}
}
All files in the specified directories should also export a cli definition object to be loaded. When checking if a custom file is indeed a command fails, the file is skipped. This also results in files depending on missing dependencies, like dev dependencies in production environments, not registering as a command.
CLI Definition
// Export a cliDefinition to be seen as a command.
/** @type {import("@compas/cli").CliCommandDefinitionInput} */
export const cliDefinition = {
// `compas todo`
// required
name: "todo",
// Description that is printed in line in the command list
// required
shortDescription: "Manage todo items",
// Used for `compas todo --help` or `compas help todo`
longDescription: "",
// Optional object
modifiers: {
// Prints the command help output if no sub command is passed.
// Defaults to false
isCosmetic: true,
// When set to true, instead of matching on the name any value can be passed, i.e `compas run generate`, `compas run foo`.
// Defaults to false.
isDynamic: false,
// When set to true, this command allows '--watch' and `cli watch [command.name]`, see 'watchSettings' below, to tune the behaviour.
// Defaults to false.
isWatchable: false,
},
// Optional flag array. Sub commands also allow the flags defined by their parents.
flags: [
{
// This name is used to store the value
// required
name: "namespace",
// The flag name, requires to be prefixed with `--`
// requirered
rawName: "--namespace",
// Description to show in help output.
description: "Only return todo's in this namespace",
// Optional modifiers object
modifiers: {
// Make this flag required.
isRequired: false,
// This flag can be repeated, resulting in an array that is passed to the executor.
isRepeatable: false,
// This flag will not show up in any help output.
isInternal: false,
},
// Optional value specification
value: {
// Give a type to the value that can be passed. The parser also does the conversion.
// "boolean" (default): accepts no value, 'true', '1', 'false' and '0'.
// "number" accepts any value that converts to a number
// "string" Accept any value.
// "booleanOrString" A combination of "boolean" and "string", giving the "boolean" parser precedence.
specification: "string",
// Optional validator, can return a Promise.
// This example checks if the flag value is a path.
validator: (value) => {
const isValid = existsSync(value);
if (isValid) {
return {
isValid,
};
}
return {
isValid,
error: {
message: `Could not find the specified file relative to the current working directory. Make sure it exists.`,
},
};
},
// Optional completions function, see below at 'dynamicValue.completions'.
// completions: () => {},
},
},
],
// Optional sub commands, this is required if this command is `modifiers.isCosmetic`.
subCommands: [
// Minimal command definition
{
name: "list",
shortDescription: "List all todo's.",
},
],
// An optional executor. If a command does not have an executor, the executor of it's (recursive) parent is used.
executor: cliExecutor,
// Extra properties for 'modifiers.isWatchable' commands
// Defaults to
// { extensions: ["js", "json"], ignorePatterns: [".cache", "coverage", "node_modules"], }
watchSettings: {
// Specific extensions to watch for
// Is optional
extensions: ["js", "json"],
// Specific patterns to filter out.
// Use this if your program or another program writes files that you don't want this command to be restarted for.
ignorePatterns: [".cache"],
},
// Extra prperties for 'modifiers.isDynamic' commands
dynamicValue: {
// Called when parsing the command. May return a Promise.
validator: (value) => {
const isValid = ["toggle", "add"].includes(value);
if (isValid) {
return {
isValid,
};
}
// Full error message: "Invalid sub command 'xxx' for 'compas todo'. Allowed values are 'toggle' and 'add'.
return {
isValid,
error: {
message: "Allowed values are 'toggle' and 'add'.",
},
};
},
// Called for shell auto-complete, may return a promise.
// Depending on the shell that is used, some features may or may not work.
completions: () => {
return {
completions: [
{
// Get directory completions
type: "directory",
},
{
// Get file completions
type: "file",
},
{
// A direct completion for the user
type: "completion",
name: "toggle",
},
{
// A direct completion for the user
type: "completion",
name: "add",
// optional
description: "Add a new todo",
},
{
// Print message with specification and description
type: "value",
specification: "string",
// Optional
description: "A todo name",
},
],
};
},
},
};
/**
* Is called when this command or a sub command is matched.
*
* @param {import("@compas/stdlib").Logger} logger
* @param {import("@compas/cli").CliExecutorState} state
* @returns {Promise<import("@compas/cli").CliResult>}
*/
async function cliExecutor(logger, state) {
logger.info(state.command);
logger.info(state.flags);
return {
exitStatus: "passed",
};
}