The plugin-first host provides a composable way to build dotenv-aware CLIs. It validates options strictly, resolves dotenv context once per invocation, and exposes lifecycle hooks for plugins.
#!/usr/bin/env node
import type { Command } from 'commander';
import { GetDotenvCli } from '@karmaniverous/get-dotenv/cliHost';
import { batchPlugin } from '@karmaniverous/get-dotenv/plugins/batch';
const program: Command = new GetDotenvCli('mycli').use(batchPlugin());
await (program as unknown as GetDotenvCli).resolveAndLoad();
await program.parseAsync();
resolveAndLoad()
produces a context { optionsResolved, dotenv, plugins? }
.Use the branding helper to set the CLI’s name/description and automatically display the version at the top of help (-h). When you provide importMetaUrl
, the host resolves the nearest package.json and uses its version
. If you don’t pass a helpHeader
, the host prints a sensible default <name> v<version>
as a header.
#!/usr/bin/env node
import type { Command } from 'commander';
import { GetDotenvCli } from '@karmaniverous/get-dotenv/cliHost';
const program: GetDotenvCli = new GetDotenvCli('toolbox');
await program.brand({
importMetaUrl: import.meta.url, // resolves version from your package.json
description: 'Toolbox CLI', // optional
// helpHeader: 'My Toolbox v1.2.3', // optional; default uses "<name> v<version>"
});
// … wire options/plugins …
await program.parseAsync();
Notes:
brand({ importMetaUrl })
so getdotenv vX.Y.Z
appears at the top of -h
output.You can add your own root options and group them under “App options” in help using tagAppOptions
. Install base flags and merging with attachRootOptions().passOptions()
so values flow into the merged options bag:
import type { Command } from 'commander';
import {
GetDotenvCli,
readMergedOptions,
} from '@karmaniverous/get-dotenv/cliHost';
import { definePlugin } from '@karmaniverous/get-dotenv/cliHost';
const program: GetDotenvCli = new GetDotenvCli('mycli');
await program.brand({ importMetaUrl: import.meta.url, description: 'My CLI' });
program
.attachRootOptions() // install base flags (-e, --paths, etc.)
.tagAppOptions((root) => {
root.option('--foo <value>', 'custom app option'); // appears under "App options" in -h
})
.use(
definePlugin({
id: 'print',
setup(cli) {
cli.ns('print').action((_args, _opts, thisCommand) => {
// Read the merged root options bag. Custom keys flow through.
const bag = readMergedOptions(thisCommand) ?? {};
const foo = (bag as unknown as { foo?: string }).foo;
console.log(`foo=${foo ?? ''}`);
});
},
}),
)
.passOptions(); // merge flags (parent < current), compute dotenv context
await program.parseAsync();
Example:
mycli -e dev --foo bar print
# -> foo=bar
Notes:
tagAppOptions
only affects help grouping; values are parsed by Commander and merged by passOptions()
.readMergedOptions(thisCommand)
in actions (or cli.getOptions()
from code that has a handle on the host) to read the merged options bag. Custom keys are present at runtime even if not in the default type.import type { GetDotenvCli } from '@karmaniverous/get-dotenv/cliHost';
import { definePlugin } from '@karmaniverous/get-dotenv/cliHost';
export const myPlugin = definePlugin({
id: 'my',
setup(cli: GetDotenvCli) {
cli
.ns('my')
.description('My commands')
.action(async () => {
const ctx = cli.getCtx?.();
// Use ctx.dotenv or ctx.plugins['my']...
});
},
async afterResolve(cli, ctx) {
// Initialize any clients/secrets using ctx.dotenv
// Attach per-plugin state under ctx.plugins by convention:
ctx.plugins ??= {};
ctx.plugins['my'] = { ready: true };
},
});
Composition:
const parent = definePlugin({ id: 'parent', setup() {} })
.use(definePlugin({ id: 'childA', setup() {} }))
.use(definePlugin({ id: 'childB', setup() {} }));
Plugins install parent → children; afterResolve
also runs parent → children.
Prefer passing the resolved env explicitly to subprocesses invoked by your commands:
import { execaCommand } from 'execa';
// inside a command action:
const ctx = cli.getCtx?.();
await execaCommand('node -e "console.log(process.env.MY_VAR)"', {
env: { ...process.env, ...ctx.dotenv },
stdio: 'inherit',
});
The shipped CLI uses a nested-CLI strategy by placing the merged CLI options on process.env.getDotenvCliOptions
(JSON) so child getdotenv
invocations can inherit the parent’s defaults and flags.
Use the built-in scaffolder to create config files and a starter CLI:
# JSON config + .local + CLI skeleton named “acme”
getdotenv init . --config-format json --with-local --cli-name acme --force
Notes:
--yes
unless --force
.__CLI_NAME__
tokens with your chosen name.--yes
(Skip All) when stdin or stdout is not a TTY OR when a CI-like environment variable is present (CI
, GITHUB_ACTIONS
, BUILDKITE
, TEAMCITY_VERSION
, TF_BUILD
).--force
> --yes
> auto-detect (non-interactive => Skip All).The plugin host and the generator use the config loader/overlay path by default (always-on). When no config files are present, the loader is a no-op. See the “Config files and overlays” guide for discovery, formats, and precedence. There is no switch to enable this behavior; it is always active.
The AWS base plugin resolves profile/region and acquires credentials using a safe cascade (env-first → CLI export → (optional) SSO login → static fallback), then writes them into process.env
and mirrors them under ctx.plugins.aws
.
You can also use the aws
subcommand to establish a session and optionally forward to the AWS CLI:
getdotenv aws --profile dev --login-on-demand
Establishes credentials/region according to overrides and exits 0.--
are passed through):getdotenv aws --profile dev --login-on-demand -- sts get-caller-identity
Uses the same exec path as cmd
: shell-off by default for binaries, honors script/global shell overrides, and supports CI-friendly capture via --capture
or GETDOTENV_STDIO=pipe
.NPM script patterns:
{ "scripts": { "aws": "getdotenv aws" } }
Then:npm run aws -- --profile dev -- -- s3 ls
{
"scripts": {
"aws:whoami": "getdotenv aws --login-on-demand -- sts get-caller-identity"
}
}