Authoring Plugins#

The sigx CLI is extensible: any package installed in a project can contribute commands. Commands like dev, build, and preview are themselves provided by plugins (@sigx/vite, @sigx/ssg, @sigx/lynx-cli). This guide shows how to write your own.

How discovery works#

When the CLI runs, it scans the current project's package.json dependencies and devDependencies. For each dependency it reads node_modules/<dep>/package.json and looks for a top-level sigx-cli field that points at a plugin module:

JSON
{
    "name": "my-sigx-plugin",
    "sigx-cli": {
        "plugin": "./dist/plugin.js",
        "requires": ">=0.4.0"
    }
}
  • plugin is a path, relative to the dependency's package root, to a module that default-exports a SigxPlugin. The CLI dynamically imports that module, takes its default export, calls detect(cwd), and registers the plugin's commands only if detect returns true.
  • requires (optional) is the semver range of @sigx/cli your plugin's contract needs — ^x.y.z, >=x.y.z, or an exact version. If the running sigx binary is older than your range, the plugin still loads (best effort) but the CLI prints a prominent warning telling the user to update. Declare "requires": ">=0.4.0" for any plugin written against the @sigx/args arg contract described below — that is the floor where it landed.

Discovery inspects the current working directory's package.json and node_modules. All discovery failures are swallowed silently, so a broken plugin never crashes the CLI.

Defining a plugin#

Use definePlugin for type-safe authoring. It is an identity function — it returns the plugin unchanged but gives you full type checking against the SigxPlugin contract. Import it from @sigx/cli/plugin (it is also re-exported from @sigx/cli); the same module re-exports a, the @sigx/args builder you declare arguments with, so plugins keep a single import surface:

TypeScript
import { definePlugin } from '@sigx/cli/plugin';

export default definePlugin({
    name: 'my-plugin',
    detect: (cwd) => true,
    commands: {
        hello: {
            description: 'Say hello',
            async run({ cwd, args, logger }) {
                logger.log(`hello from ${cwd}`);
            },
        },
    },
});

If you prefer, you can use the satisfies operator instead of definePlugin for the same type safety:

TypeScript
import type { SigxPlugin } from '@sigx/cli/plugin';

export default {
    name: 'my-plugin',
    detect: (cwd) => true,
    commands: {
        hello: {
            description: 'Say hello',
            async run({ cwd, args, logger }) {
                logger.log(`hello from ${cwd}`);
            },
        },
    },
} satisfies SigxPlugin;

The Logger passed to run has only log, warn, and error methods — there is no info. Messages are prefixed with [sigx] and warn / error are uppercased.

Gating activation with detect#

detect(cwd) decides whether your plugin is relevant to the current project. Return true to register your commands, false to stay out of the way. A common pattern is to look for a config file or a marker dependency:

TypeScript
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { definePlugin } from '@sigx/cli/plugin';

export default definePlugin({
    name: 'my-plugin',
    detect: (cwd) => existsSync(join(cwd, 'my-plugin.config.ts')),
    commands: {
        // ...
    },
});

Declaring command arguments#

Commands declare their arguments through the args map, using the fluent @sigx/args builders exposed as a (re-exported from @sigx/cli/plugin). Each entry is a builder you chain to describe its type, aliases, default, and help — and the chain drives the type your handler receives in ctx.args:

TypeScript
import { definePlugin, a } from '@sigx/cli/plugin';

export default definePlugin({
    name: 'deployer',
    detect: () => true,
    commands: {
        deploy: {
            description: 'Deploy the built site',
            args: {
                target: a.enum(['staging', 'production']).default('staging').describe('Deploy target'),
                port: a.number().alias('p').default(8788).describe('Port to listen on'),
                force: a.boolean().describe('Skip confirmation'),
                files: a.rest().describe('Files to deploy'),
            },
            async run({ args, logger }) {
                logger.log(`Deploying to ${args.target} on :${args.port}`);
                if (args.force) logger.warn('Forcing deploy without confirmation');
            },
        },
    },
});

The common builders:

BuilderParsesInferred type
a.string()--name value, --name=valuestring
a.number()finite numbers (incl. negative)number
a.boolean()--flag, --no-flag, --flag=falseboolean
a.enum(['a', 'b'])exact member'a' | 'b'
a.positional()non-flag tokens, in declaration orderstring
a.rest()the remaining positional tokensstring[]

Chain refiners to shape them: .alias('p', …) (single characters become short flags like -p), .default(v), .required(), .multiple() (repeatable → array), .describe(text). Builders are immutable — each refiner returns a new builder. For the full builder/refiner reference see the @sigx/args guide.

Parsing behavior flows straight from @sigx/args, so plugin commands inherit it:

  • Unknown flags are an error. An undeclared --flag fails with a clear message rather than being silently accepted.
  • Positionals bind only to declared builders. Bare positional tokens fill your a.positional() / a.rest() args in order; there is no loose positional bag.
  • -- is a strict separator. Everything after a bare -- lands verbatim in ctx.args._ (a string[]) and is never parsed as flags.
  • Booleans negate with --no-<flag>, and kebab/camel spellings both resolve (--dry-rundryRun).
  • --help is generated from the @sigx/args help catalog — don't hand-write usage text. sigx <your-command> --help lists every declared arg, its type, default, and description automatically.

A malformed args schema (e.g. an alias collision) throws a DefinitionError at registration. The CLI catches it, logs a warning, and skips just that command — one bad schema never takes down the whole CLI.

Command options#

Beyond description, args, and run, a command can set three optional fields:

TypeScript
commands: {
    deploy: {
        description: 'Deploy the built site',
        aliases: ['ship', 'push'],   // also answers to `sigx ship` / `sigx push`
        hidden: false,               // set true to keep it out of `sigx --help`
        allowUnknownFlags: true,     // collect undeclared flags into ctx.unknownFlags instead of erroring
        args: { /* … */ },
        async run(ctx) {
            // forward extras verbatim to an underlying tool
            await runRsync([...ctx.args._, ...(ctx.unknownFlags ?? [])]);
        },
    },
},
  • aliases — extra names the command also answers to. If an alias collides with another command, the CLI warns and a direct command name always beats an alias, so a plugin can't shadow a built-in by aliasing over it.
  • hidden — omit the command from sigx --help while keeping it fully dispatchable (handy for internal or experimental commands).
  • allowUnknownFlags — instead of erroring on an undeclared flag, collect the raw tokens into ctx.unknownFlags (an optional string[], populated only when this is set — hence the ctx.unknownFlags ?? [] above). The command decides whether and how to forward them; nothing is passed through automatically. Use it for wrapper commands that hand flags to an underlying tool. Without it, unknown flags remain an error (the default).

Nested subcommands within a plugin command are not yet supported.

Standalone commands with defineCommand#

Inside a definePlugin literal every command already infers its ctx.args per key. When you factor a command out of the literal — into its own module or variable — wrap it in defineCommand so it keeps that inference instead of widening to the legacy Record:

TypeScript
import { defineCommand, definePlugin, a } from '@sigx/cli/plugin';

export const deploy = defineCommand({
    description: 'Deploy the built site',
    args: { target: a.enum(['staging', 'production']).default('staging') },
    async run({ args, logger }) {
        logger.log(`Deploying to ${args.target}`); // args.target: 'staging' | 'production'
    },
});

export default definePlugin({
    name: 'deployer',
    detect: () => true,
    commands: { deploy },
});

defineCommand is an identity helper (like definePlugin) — it returns the command unchanged and only drives typing. The new command options and the typed context landed in @sigx/cli 0.5, so declare "requires": ">=0.5.0" in your sigx-cli field if you rely on aliases / hidden / allowUnknownFlags.

The command context#

Every command's run handler receives a single context object:

TypeScript
async run(ctx) {
    ctx.cwd;          // process.cwd()
    ctx.args;         // parsed args, INFERRED from your `args` builders; ctx.args._ = post-`--` tokens
    ctx.logger;       // prefixed Logger with log/warn/error
    ctx.plugins;      // every plugin discovered for this project (for runShell)
    ctx.cliVersion;   // the running `sigx` binary's version, for feature detection
    ctx.unknownFlags; // undeclared flags — only when the command sets allowUnknownFlags
}

ctx.args is fully typed from the args builders you declared — no casts. Inside definePlugin, each command's args shape infers per key, so args.port above is a number, args.target is 'staging' | 'production', and so on. The context is a TypedCommandContext<S> — an intersection over the plain CommandContext, so it stays assignable to CommandContext and any helper you've typed against the base still accepts it. A command with no args (or a hand-annotated CommandContext) falls back to the legacy args: Record<string, unknown>.

ctx.plugins and ctx.cliVersion exist so a shell-hosting command (for example a long-running dev) can merge peer plugins' TUI contributions and adapt to the CLI version. See The shell runtime.

Contributing to the shell (TUI)#

Beyond plain commands, a plugin can ship a TUI contribution — tabs, slash commands, single-key shortcuts, and status-line items that get merged into whichever plugin hosts the shell runtime. Add a tui field to your plugin:

TypeScript
import { definePlugin } from '@sigx/cli/plugin';

export default definePlugin({
    name: 'my-plugin',
    detect: () => true,
    commands: { /* … */ },
    tui: {
        tabs: [{ id: 'routes', label: 'Routes', render: () => myRoutesView() }],
        commands: [{ name: '/reload', description: 'reload config', run: (shell) => shell.say('reloaded') }],
        shortcuts: [{ key: 'r', label: 'reload', run: (shell) => shell.say('reloaded') }],
        status: () => [{ label: 'mode', value: 'dev', tone: 'success' }],
        setup(shell) {
            const server = startWatcher();
            // Returned teardown runs before the process exits (also on Ctrl+C / SIGTERM).
            return () => server.close();
        },
    },
});

The host plugin's command merges these by passing ctx.plugins to runShell({ plugins }); host entries win conflicts (a duplicate tab id, command name, or shortcut key from a contributor is skipped). The setup(shell) lifecycle runs once when the shell comes up — start servers and watchers there, and return a function (or call shell.onExit) to tear them down cleanly. See The shell runtime for the host side, the ShellHandle API, and the non-TTY fallback.

Packaging checklist#

To ship a plugin:

  1. Build a module that default-exports a SigxPlugin (via definePlugin or satisfies SigxPlugin).
  2. Point your package's package.json sigx-cli.plugin field at that built module, and set "requires": ">=0.4.0" (or higher) so the CLI can warn users on too-old a binary.
  3. Publish the package and add it to a project's dependencies.

Once installed, run npx sigx <your-command> in a project where your detect returns true.

Command name conflicts#

If two plugins register a command with the same name, the last one discovered wins, and the CLI logs a warning via logger.warn:

[sigx] WARN Plugin "X" overrides command "Y"

See also#

  • API Reference — the full SigxPlugin, PluginCommand, CommandContext, TuiContribution, and Logger types.
  • The shell runtimerunShell, the ShellHandle API, and the TUI contribution model.
  • @sigx/args guide — the fluent arg builders behind PluginCommand.args.
  • Commands — the built-in and plugin-provided commands.