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, 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.
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/
build.ts
user/
add.ts
remove.tsthe discovered command paths are:
admin build
admin user add
admin user removerunProgram() 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 bashYou 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 },
version: false,
completion: false,
});Running statically imported commands
When command modules need to be visible to a bundler or single-file packager, import them manually and pass them 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.
File names and extensions
The relative file path becomes the command path after removing the configured suffix. Compound suffixes are supported, so user/add.cmd.ts can become user add when .cmd.ts is listed before .ts.
By default, @optique/discover chooses extensions for the current runtime:
| Runtime | Default 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) 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"],
});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.
Path conflicts
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 file-vs-namespace conflicts such as:
commands/
user.ts
user/
add.tsIn that layout, user would need to be both a leaf command and a namespace for user add. Move the shared behavior into a helper module or choose a deeper leaf command path instead.
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
- You want command modules to be visible through ordinary static imports
Use plain @optique/core and @optique/run when:
- The command tree is small enough to define directly with
command()andor() - You need lazy command loading or a custom plugin registry
- You want to parse commands without coupling them to handlers