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:
{
"name": "my-sigx-plugin",
"sigx-cli": {
"plugin": "./dist/plugin.js",
"requires": ">=0.4.0"
}
}
pluginis a path, relative to the dependency's package root, to a module that default-exports aSigxPlugin. The CLI dynamically imports that module, takes its default export, callsdetect(cwd), and registers the plugin's commands only ifdetectreturnstrue.requires(optional) is the semver range of@sigx/cliyour plugin's contract needs —^x.y.z,>=x.y.z, or an exact version. If the runningsigxbinary 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/argsarg 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:
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:
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:
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:
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:
| Builder | Parses | Inferred type |
|---|---|---|
a.string() | --name value, --name=value | string |
a.number() | finite numbers (incl. negative) | number |
a.boolean() | --flag, --no-flag, --flag=false | boolean |
a.enum(['a', 'b']) | exact member | 'a' | 'b' |
a.positional() | non-flag tokens, in declaration order | string |
a.rest() | the remaining positional tokens | string[] |
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
--flagfails 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 inctx.args._(astring[]) and is never parsed as flags.- Booleans negate with
--no-<flag>, and kebab/camel spellings both resolve (--dry-run↔dryRun). --helpis generated from the@sigx/argshelp catalog — don't hand-write usage text.sigx <your-command> --helplists 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:
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 fromsigx --helpwhile keeping it fully dispatchable (handy for internal or experimental commands).allowUnknownFlags— instead of erroring on an undeclared flag, collect the raw tokens intoctx.unknownFlags(an optionalstring[], populated only when this is set — hence thectx.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:
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:
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:
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:
- Build a module that default-exports a
SigxPlugin(viadefinePluginorsatisfies SigxPlugin). - Point your package's
package.jsonsigx-cli.pluginfield at that built module, and set"requires": ">=0.4.0"(or higher) so the CLI can warn users on too-old a binary. - 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, andLoggertypes. - The shell runtime —
runShell, theShellHandleAPI, and the TUI contribution model. @sigx/argsguide — the fluent arg builders behindPluginCommand.args.- Commands — the built-in and plugin-provided commands.
