Skip to content
On this page

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:

json
{
  "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

js
// 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",
  }
}