Skip to main content
Version: Next

Custom CLI Modules

You can extend the Backstage CLI with custom commands by creating your own CLI modules. A CLI module is a package that registers one or more commands using the createCliModule API from @backstage/cli-node. Once installed as a dependency in your project, the CLI discovers and loads it automatically.

Scaffolding a new module

To scaffold a module, use the built-in template:

yarn new

Select the cli-module template from the interactive menu. This creates a new package with the correct structure, including a sample command, a standalone bin script, and all the required configuration.

Module structure

A CLI module package has the following structure:

packages/cli-module-example/
bin/
backstage-cli-module-example # Standalone bin script
src/
commands/
example.ts # Command implementation
index.ts # Module definition
package.json

package.json

The package.json must set backstage.role to "cli-module". This is how the CLI identifies the package as a module during dependency scanning.

package.json
{
"name": "@mycompany/cli-module-example",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "cli-module"
},
"bin": "bin/backstage-cli-module-example",
"files": ["dist", "bin"],
"dependencies": {
"@backstage/cli-common": "...",
"@backstage/cli-node": "...",
"cleye": "..."
},
"devDependencies": {
"@backstage/cli": "..."
}
}

Module definition

The module entry point uses createCliModule to register commands:

src/index.ts
import { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';

export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['example'],
description: 'An example command',
execute: { loader: () => import('./commands/example') },
});
},
});

The createCliModule API

The createCliModule function accepts an options object with two fields:

  • packageJson — An object with at least a name field, typically the imported package.json. The name is used in error messages when command conflicts occur.
  • init — An async callback that receives a registry object. Use the registry's addCommand method to register commands.

The init callback runs when the module is loaded, not when a command is executed. Keep it lightweight and use the deferred loader pattern for commands with heavy dependencies.

Defining commands

Each command is defined by a CliCommand object passed to reg.addCommand:

reg.addCommand({
path: ['my-tool', 'run'],
description: 'Run the tool',
execute: async ({ args, info }) => {
// Command implementation
},
});

Command path

The path array defines how the command is invoked. Each element becomes a level in the command hierarchy:

  • ['info'] registers backstage-cli info
  • ['repo', 'test'] registers backstage-cli repo test
  • ['actions', 'sources', 'add'] registers backstage-cli actions sources add

Intermediate nodes in the path are created automatically and appear as command groups in the help output.

Description

A short string shown in the help output next to the command name.

Deprecated and experimental flags

Commands can be marked with deprecated: true or experimental: true. These commands are hidden from the --help output but can still be used.

Execute function

The execute field supports two patterns:

Direct execution — an inline async function:

reg.addCommand({
path: ['greet'],
description: 'Print a greeting',
execute: async ({ args, info }) => {
console.log('Hello!');
},
});

Deferred loading — a loader that dynamically imports the command implementation. This is the recommended pattern because it avoids loading heavy dependencies until the command is actually invoked:

reg.addCommand({
path: ['greet'],
description: 'Print a greeting',
execute: { loader: () => import('./commands/greet') },
});

When using the deferred loader pattern, the command file must export the execute function as its default export:

src/commands/greet.ts
import type { CliCommandContext } from '@backstage/cli-node';

export default async ({ args, info }: CliCommandContext) => {
console.log('Hello!');
};

Command context

The execute function receives a CliCommandContext with two fields:

  • args — An array of the remaining command-line arguments after the command path has been resolved. For example, if the user runs backstage-cli greet --name World, the args array would be ['--name', 'World'].
  • info — An object with usage (the full command path as shown in help, for example "backstage-cli greet") and name (the command name, for example "greet").

Parsing flags

Commands typically use cleye to parse flags from the args array. This is the same library used by all built-in CLI modules:

src/commands/greet.ts
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';

export default async ({ args, info }: CliCommandContext) => {
const { flags } = cli(
{
name: info.usage,
flags: {
name: {
type: String,
description: 'Name to greet',
default: 'World',
},
loud: {
type: Boolean,
description: 'Shout the greeting',
},
},
},
undefined,
args,
);

const greeting = `Hello, ${flags.name}!`;
console.log(flags.loud ? greeting.toUpperCase() : greeting);
};

Standalone execution

Each CLI module can also run as a standalone program, without the full @backstage/cli. This is useful for distributing a module independently. The scaffolded template includes a bin script that does this:

bin/backstage-cli-module-example
#!/usr/bin/env node
const path = require('node:path');

/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('node:fs').existsSync(
path.resolve(__dirname, '../src'),
);

if (isLocal) {
require('@backstage/cli-node/config/nodeTransform.cjs');
}

const { runCli } = require('@backstage/cli-node');
const cliModule = require(isLocal ? '../src/index' : '..').default;
const pkg = require('../package.json');
runCli({ modules: [cliModule], name: pkg.name, version: pkg.version });

The isLocal check detects whether a src/ directory exists next to the bin script. When running from source during development, it registers a Node.js transform so TypeScript files can be loaded directly. When running from a published package, it loads the compiled output instead.

Installing your module

Once your module is published or available as a workspace package, add it as a dependency in your project root's package.json:

package.json
{
"devDependencies": {
"@backstage/cli": "...",
"@mycompany/cli-module-example": "..."
}
}

The CLI discovers it automatically on the next run. Your custom commands appear in the --help output alongside the default commands.

If your module registers a command path that conflicts with a default module from @backstage/cli-defaults, your module takes precedence and the conflicting default module is silently skipped. See CLI Modules for more details on conflict resolution.