Skip to content

Command discovery

Optique's core command combinators work well when the whole command tree fits comfortably in one module. Larger applications often want a file layout where each command owns its parser, metadata, and handler. The @optique/discover package provides that layer: it scans a command directory, imports command modules, builds a nested parser tree, and dispatches to the matched command handler.

The package is named @optique/discover because its main job is command module discovery. The alternative name @optique/program would overlap with @optique/core/program, which already provides parser-and-metadata objects for man pages and runner integration.

WARNING

Command discovery is a runtime feature, not a static registry. It reads the command directory and imports matching modules dynamically, so bundlers cannot reliably see which command files are used. If your CLI depends on tree shaking, static bundling, or single-file executable packaging, use commandsFromModules() with a static module map, or import command modules manually and pass them to runProgram() with commands.

Command modules

A command module default-exports a value created with defineCommand(). The parser describes the command-specific arguments and options, metadata feeds help and completion output, and handler receives the parsed value.

import { 
defineCommand
} from "@optique/discover/command";
import {
object
} from "@optique/core/constructs";
import {
message
} from "@optique/core/message";
import {
option
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
export default
defineCommand
({
parser
:
object
({
name
:
option
("--name",
string
()),
}),
metadata
: {
brief
:
message
`Add a user.`,
},
handler
(
value
) {
console
.
log
(`Adding ${
value
.
name
}.`);
}, });

defineCommand() preserves the parser's inferred value type for handler. If you change the parser, TypeScript checks the handler against the new shape. When commands are passed manually to runProgram(), add a path field to the command definition. File-based discovery can omit path; if it is present, it must match the path derived from the file name. An empty path ([]) defines the root command when commands are passed manually.

Running a discovered program

Point runProgram() at a command directory and provide root program metadata:

import { 
runProgram
} from "@optique/discover";
import {
message
} from "@optique/core/message";
await
runProgram
({
dir
: new
URL
("./commands/", import.meta.
url
),
metadata
: {
name
: "admin",
version
: "1.0.0",
brief
:
message
`Administrative command-line tools.`,
}, });

With this file layout:

commands/
  index.ts
  build.ts
  user/
    index.ts
    add.ts
    remove.ts

the discovered command paths are:

admin
admin build
admin user
admin user add
admin user remove

runProgram() uses @optique/run internally. Help and shell completion are enabled in both command and option forms by default, and version output is enabled when metadata.version is present:

admin --help
admin help
admin --version
admin completion bash
admin --completion bash

You can disable or customize these runner features with the same option shapes accepted by run():

import { 
runProgram
} from "@optique/discover";
import {
message
} from "@optique/core/message";
await
runProgram
({
dir
: new
URL
("./commands/", import.meta.
url
),
metadata
: {
name
: "admin",
brief
:
message
`Administrative command-line tools.`,
},
help
: {
option
: true },
showUsage
: false,
commandList
: "top-level",
version
: false,
completion
: false,
});

showUsage: false is useful for larger command trees because root help can act as a compact command menu: the program brief and command list remain, but the expanded Usage: synopsis is omitted. The same setting applies when aboveError: "help" renders a full help page above a parse error. Pass commandList: "top-level" when the root command menu should list only first-level command groups and let users drill down with <command> --help.

Running with static module maps

Bundlers often provide a static module map API for glob imports. For example, Vite and Rollup-based toolchains can statically see import.meta.glob() when the pattern is literal. commandsFromModules() turns that module map into the same command entries that file-system discovery would have produced:

import { 
message
} from "@optique/core/message";
import {
commandsFromModules
,
runProgram
} from "@optique/discover";
const
modules
= import.meta.
glob
("./commands/**/*.ts", {
eager
: true });
await
runProgram
({
commands
:
commandsFromModules
(
modules
, {
base
: "./commands",
extensions
: [".ts"],
}),
metadata
: {
name
: "admin",
version
: "1.0.0",
brief
:
message
`Administrative command-line tools.`,
}, });

Each module value must expose a default export created with defineCommand(). The module map key is treated like a file path relative to base, so ./commands/user/add.ts becomes user add. Entry files keep the same meaning as runtime discovery: ./commands/index.ts defines the root command, and ./commands/user/index.ts defines user.

Pass extensions when the module map keys keep source file suffixes such as .ts after bundling. Compound suffixes are matched with the same precedence as dynamic discovery, and TypeScript declaration files such as .d.ts, .d.mts, and .d.cts are ignored.

The returned entries can also be passed to createProgramParser() when you want to build the parser directly instead of using runProgram().

Generating static module maps

If your toolchain does not provide an eager glob API, or you do not want to maintain the static module map by hand, use the optique-discover command to generate a TypeScript module:

optique-discover ./commands --output ./commands.generated.ts --extension .ts

The generated module imports every command file and exports the commandsFromModules() result as its default export:

// biome-ignore-all lint: generated file
// biome-ignore-all format: generated file
// Generated by optique-discover. Do not edit.
/* eslint-disable */
// @ts-nocheck
// deno-lint-ignore-file
// deno-fmt-ignore-file

import { 
commandsFromModules
} from "@optique/discover";
import * as
cmd0
from "./commands/build.ts";
import * as
cmd1
from "./commands/user/add.ts";
export default
commandsFromModules
(
{ "./commands/build.ts":
cmd0
,
"./commands/user/add.ts":
cmd1
,
}, {
base
: "./commands",
extensions
: [".ts"],
}, );

Use that generated module from your CLI entry point:

import { 
message
} from "@optique/core/message";
import {
runProgram
} from "@optique/discover";
import
commands
from "./commands.generated.ts";
await
runProgram
({
commands
,
metadata
: {
name
: "admin",
version
: "1.0.0",
brief
:
message
`Administrative command-line tools.`,
}, });

Pass --watch during development to regenerate when command files are added, removed, or renamed:

optique-discover ./commands --output ./commands.generated.ts \
  --extension .ts --watch

Watch mode tracks the command file set, not file contents. Editing a command module does not rewrite the generated module because the module map has not changed.

Generated modules include a fixed header with lint, formatter, TypeScript check suppression, and machine-owned marker comments for tools that support file-level comments. Biome suppressions appear first because Biome expects file-level suppressions at the beginning of the file. The module map imports command files as static TypeScript specifier strings, but command module exports are still validated at runtime by commandsFromModules().

Prettier and Oxfmt do not have a portable TypeScript file-level suppression comment that optique-discover can emit by default. Exclude the generated module through formatter configuration instead, such as .prettierignore or Oxfmt ignore paths.

Running manually imported commands

For a small static command registry, or when you want to assign command paths manually, import modules yourself and pass commands as commands. Each command declares its own path:

import { 
object
} from "@optique/core/constructs";
import {
message
} from "@optique/core/message";
import {
withDefault
} from "@optique/core/modifiers";
import {
option
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
defineCommand
,
runProgram
} from "@optique/discover";
const
build
=
defineCommand
({
path
: ["build"],
parser
:
object
({
target
:
withDefault
(
option
("--target",
string
()), "app"),
}),
metadata
: {
brief
:
message
`Build the project.`,
},
handler
(
value
) {
console
.
log
(`Building ${
value
.
target
}.`);
}, }); await
runProgram
({
commands
: [
build
],
metadata
: {
name
: "admin",
version
: "1.0.0",
brief
:
message
`Administrative command-line tools.`,
}, });

commands and dir are mutually exclusive. Use commands when static imports matter; use dir when the runtime file layout is the command registry. Prefer commandsFromModules() when you want to keep deriving paths from a command directory layout.

Lifecycle hooks

This API is available since Optique 1.2.0.

runProgram() normally dispatches straight to the matched command's handler. A hooks option wraps every handler with beforeEach, afterEach, and onError, so cross-cutting concerns such as log scopes, tracing spans, lazy resource setup, and failure reporting can live in one place instead of inside every command. Hooks are opt-in: a runProgram() call without them behaves exactly as before.

import { 
runProgram
} from "@optique/discover";
interface HookResource { readonly
label
: string;
readonly
startedAt
: number;
} await
runProgram
<HookResource>({
dir
: new
URL
("./commands/", import.meta.
url
),
metadata
: {
name
: "admin",
version
: "1.0.0" },
hooks
: {
beforeEach
({
path
}) {
const
label
=
path
.
length
> 0 ?
path
.
join
(" ") : "admin";
return {
resource
: {
label
,
startedAt
:
Date
.
now
() } };
},
afterEach
(
context
) {
const
resource
=
context
.
resource
;
if (
resource
== null) return;
const {
label
,
startedAt
} =
resource
;
console
.
log
(`${
label
} finished in ${
Date
.
now
() -
startedAt
}ms.`);
},
onError
(
_context
,
error
) {
console
.
error
("Handler failed:",
error
);
}, }, });

beforeEach receives the invocation: the matched command, its resolved command path (always populated, even when a discovered command omits an explicit path), the parsed value, and the handler. It returns a context object whose resource is threaded forward to the handler's second parameter and to afterEach/onError, so handlers and later hooks reach the resource without global state. Each hook may be synchronous or return a promise; runProgram() awaits it.

The type argument to runProgram() checks the resource returned by beforeEach and exposes the same type to the later hooks. The resource property remains optional because beforeEach may return null or void. For statically registered commands, it also checks handlers that receive the program-level context; a command whose own beforeEach replaces that context keeps its independently inferred resource type. File-discovered command handlers live in separate modules, so annotate their second parameter with ProgramHookContext<HookResource> when they consume the same program-level resource.

Hooks can also be attached to a single command through defineCommand({ hooks }) for a per-command preflight. Command-level hooks nest inside the program-level ones, and teardown unwinds in reverse:

A parse error, such as a missing option or an invalid value, is handled by @optique/run's error display before any handler or hook runs; beforeEach does not fire for it. A handler error, anything the handler, beforeEach, or afterEach throws or rejects, is passed to onError. onError observes and cleans up but does not swallow the error: runProgram() re-throws it afterward, so process exit codes are unchanged.

For a worked example with LogTape scopes and tracing spans, plus a per-command preflight, see the program-level lifecycle hooks recipe in the cookbook.

File names and extensions

The relative file path becomes the command path after removing the configured suffix. commandsFromModules() applies the same rule to module map keys after stripping its base option. Compound suffixes are supported, so user/add.cmd.ts can become user add when .cmd.ts is listed before .ts.

By default, an entry file named index maps to its containing command path:

commands/
  index.ts         # admin
  stash/
    index.ts       # admin stash
    list.ts        # admin stash list
    pop.ts         # admin stash pop

This is useful for executable parent commands. In the example above, admin stash has its own parser and handler, while admin stash list and admin stash pop remain nested commands.

By default, @optique/discover chooses extensions for the current runtime:

RuntimeDefault extensions
Deno.ts, .mts, .js, .mjs
Bun.ts, .mts, .js, .mjs
Node.js.js, .mjs, .cjs, and sometimes TypeScript too

Node.js also includes .ts, .mts, and .cts when it appears to be running with native TypeScript support, a TypeScript loader such as tsx, ts-node, tsimp, or jiti, or Node's built-in type-stripping flags.

TypeScript declaration files (.d.ts, .d.mts, and .d.cts) and test files (.spec., .test.) are ignored even when their suffix matches the configured extension list.

Pass extensions when you want an explicit policy:

import { 
runProgram
} from "@optique/discover";
await
runProgram
({
dir
: new
URL
("./commands/", import.meta.
url
),
metadata
: {
name
: "admin" },
extensions
: [".cmd.ts"],
});

Pass entryFileName to use a different entry filename, such as mod for a Deno-style layout:

import { 
runProgram
} from "@optique/discover";
await
runProgram
({
dir
: new
URL
("./commands/", import.meta.
url
),
metadata
: {
name
: "admin" },
entryFileName
: "mod",
});

Pass entryFileName: false to disable entry-file handling and treat index.ts as an ordinary index command.

NOTE

Command modules are imported eagerly during startup. This keeps discovery simple and makes help, errors, and completion aware of the full command tree. Avoid side effects at module top level other than defining the command.

Duplicate paths

Each discovered file must map to exactly one command path. Discovery rejects duplicate paths, such as build.ts and build.cmd.ts both becoming build. It also rejects duplicates introduced by entry files, such as user.ts and user/index.ts both becoming user.

A file and directory with the same name are allowed when they map to distinct command paths:

commands/
  user.ts          # user
  user/
    add.ts         # user add

In that layout, user is an executable parent command and user add is a nested command. You can also write the parent command as user/index.ts instead of user.ts, but not both at once unless you change entryFileName.

When to use command discovery

Use @optique/discover when:

  • Your CLI has enough commands that a file-per-command layout is clearer
  • Each command should keep its parser, help metadata, and handler together
  • You want @optique/run help, version, and completion behavior without manually composing the whole command tree

Use static commands with manually imported command modules when:

  • You need tree shaking, static bundling, or single-file executable packaging, and your toolchain does not provide a static module map
  • You want to assign command paths by hand instead of deriving them from module paths

Use commandsFromModules() with a static module map when:

  • You need tree shaking, static bundling, or single-file executable packaging
  • You want command modules to be visible to the bundler while keeping the file layout as the command registry

Use optique-discover when:

  • You want a checked-in or generated static command module instead of a bundler-specific glob API
  • You want watch mode to keep the generated module in sync when command files are added, removed, or renamed

Use plain @optique/core and @optique/run when:

  • The command tree is small enough to define directly with command() and or()
  • You need lazy command loading or a custom plugin registry
  • You want to parse commands without coupling them to handlers