just-bash-util
v0.1.10
Published
CLI command framework, config file discovery, and path utilities for just-bash
Maintainers
Readme
just-bash-util
CLI command framework, config file discovery, and path utilities for just-bash.
Install
npm install just-bash-util just-bashModules
just-bash-util/command — CLI framework
Type-safe command trees with fluent builders, inherited options, auto-generated help, and typo suggestions.
import { Bash } from "just-bash";
import { command, o, f, a } from "just-bash-util/command";
const cli = command("mycli", {
description: "My CLI tool",
});
const serve = cli.command("serve", {
description: "Start the dev server",
options: {
port: o.number().default(3000).alias("p").describe("Port to listen on"),
host: o.string().describe("Host to bind to"),
open: f().alias("o").describe("Open browser"),
},
args: [a.string().name("entry").describe("Entry file")],
handler: (args, ctx) => {
// args is fully typed: { port: number; host: string | undefined; open: boolean; entry: string }
return { stdout: `Listening on :${args.port}`, stderr: "", exitCode: 0 };
},
});
const bash = new Bash({ customCommands: [cli.toCommand()] });
await bash.exec("mycli serve app.ts -p 8080");Commands can also be executed directly without just-bash:
import type { Infer } from "just-bash-util/command";
// Extract handler args type externally (like z.infer)
type ServeArgs = Infer<typeof serve>;
// Execute from CLI tokens
await cli.execute(["serve", "app.ts", "-p", "8080"], ctx);
// Or invoke programmatically with typed args
await serve.invoke({ port: 8080, entry: "app.ts" }, ctx);Features:
- Composable command trees — attach pre-existing
Commandinstances with.command(child) - Subcommand nesting with automatic option inheritance
omitInheritedto exclude parent options from specific subcommands--help/-hauto-generated at every level--no-<flag>negation,-abccombined short flags,--key=valuesyntax, counted flags (-vvv→ 3)--end-of-options separator (remaining tokens go tometa.passthroughwithout consuming positional args)- Environment variable fallbacks for options
- Levenshtein-based "did you mean?" suggestions for typos
transformArgscallback to rewrite tokens before parsing — support non-standard shorthand syntax like-5→-n 5defaultSubcommandto name a child as the fallback for bare invocations — supports "dual mode" commands likegit stash/git remote- Automatic error handling — thrown errors in handlers are caught and returned as clean
ExecResultwithexitCode: 1
Options and flags
Option keys are written in camelCase and automatically converted to kebab-case for the CLI:
options: {
allowEmpty: f(), // CLI: --allow-empty handler: args.allowEmpty
dryRun: f().alias("n"), // CLI: --dry-run / -n handler: args.dryRun
message: o.string().alias("m"), // CLI: --message / -m handler: args.message
}Flags support counting mode via .count(). Repeated occurrences produce a number instead of a boolean — useful for verbosity levels (-v, -vv, -vvv):
options: {
verbose: f().alias("v").count().describe("Verbosity level"),
}
// -v → 1, -vv → 2, -vvv → 3, absent → 0
// handler receives args.verbose as numberShort flags (single-dash, single-character) require .alias(). A single-character key like b: f() creates the long flag --b, not the short flag -b. To get -b, use a descriptive key with an alias:
// ✗ b: f() → creates --b (long flag), not -b
// ✓ branch: f().alias("b") → creates --branch and -bPositional args
Args are required by default. Use .optional() for optional args, and .variadic() to collect remaining positionals into an array. Chain .optional().variadic() for zero-or-more:
args: [
a.string().name("entry"), // required single arg
a.string().name("file").optional(), // optional single arg
a.string().name("files").variadic(), // required: one or more
a.string().name("paths").optional().variadic(), // optional: zero or more → string[]
]The -- separator
The -- token stops all parsing. Tokens after -- go exclusively into meta.passthrough — they are not consumed as positional args or parsed as flags. Positional args must appear before --:
// mycli checkout main -- README.md
handler: (args, ctx, meta) => {
args.target; // "main" (positional arg before --)
meta.passthrough; // ["README.md"] (tokens after --)
}
// mycli checkout -- README.md
handler: (args, ctx, meta) => {
args.target; // undefined (no positional before --)
meta.passthrough; // ["README.md"]
}Token rewriting with transformArgs
Commands can define a transformArgs callback to rewrite the raw token array before it reaches the parser. This is useful for supporting non-standard shorthand syntax that the parser wouldn't otherwise understand:
cli.command("log", {
description: "Show commit log",
transformArgs: (tokens) =>
tokens.map((t) => (/^-(\d+)$/.test(t) ? `-n${t.slice(1)}` : t)),
options: {
maxCount: o.number().alias("n").describe("Limit output to n commits"),
},
handler: (args) => {
// git log -5 → tokens rewritten to -n5 → args.maxCount === 5
return { stdout: `showing ${args.maxCount} commits`, stderr: "", exitCode: 0 };
},
});The callback receives a mutable copy of the tokens and returns the rewritten array. It runs only in execute() (token-based invocation) — invoke() takes typed args directly, so there's nothing to transform.
Default subcommand
Commands with a "dual mode" pattern (e.g. git stash implicitly pushes, git remote lists remotes) can name a child as the fallback for bare invocations. Explicit subcommands still route normally, and typos still get "did you mean?" suggestions:
const stash = cli.command("stash", {
description: "Stash changes",
defaultSubcommand: "push", // bare "stash" or "stash -m wip" → routes to push
});
stash.command("push", { /* options, handler */ });
stash.command("pop", { /* args, handler */ });defaultSubcommand and handler are mutually exclusive.
Attaching pre-existing commands
.command() also accepts an existing Command instance. This lets you define commands independently and attach them later — useful for splitting a CLI across files, building reusable command modules, or composing subtrees from libraries:
import { command, o, f, a } from "just-bash-util/command";
// Defined independently (e.g. in another file or package)
const lint = command("lint", {
description: "Run linters",
options: { fix: f().describe("Auto-fix problems") },
handler: (args) => ({ stdout: args.fix ? "fixed" : "checked", stderr: "", exitCode: 0 }),
});
// Attach to the main CLI
const cli = command("mycli", { description: "My CLI" });
cli.command(lint);
// mycli lint --fix → "fixed"Subtrees with children are attached as a unit — the entire tree comes along:
const db = command("db", { description: "Database operations" });
db.command("migrate", { description: "Run migrations", handler: /* ... */ });
db.command("seed", { description: "Seed data", handler: /* ... */ });
cli.command(db);
// mycli db migrate
// mycli db seedIf a command is already attached to another parent, it is automatically detached from the old parent first.
just-bash-util/config — Config file discovery
Cosmiconfig-style config search that walks up the directory tree, trying conventional filenames at each level. Comments and trailing commas are supported out of the box.
import { searchConfig } from "just-bash-util/config";
// Walks up from cwd trying: .myapprc, .myapprc.json, myapp.config.json
const result = await searchConfig(ctx, { name: "myapp" });
if (result) {
result.config; // parsed config object
result.filepath; // absolute path to the file that matched
result.isEmpty; // true if config is null/undefined/empty object
}
// Find nearest package.json and return its full contents
const pkg = await searchConfig(ctx, { name: "package", searchPlaces: ["package.json"] });
// Extract a tool-specific property from package.json
const result2 = await searchConfig(ctx, {
name: "myapp",
searchPlaces: ["package.json", ".myapprc", ".myapprc.json"],
packageJsonProp: "myapp",
});Layered / cascading configs — pass merge: true to collect configs from every directory level and deep-merge them (closest wins):
const result = await searchConfig(ctx, { name: "myapp", merge: true });
// e.g. /project/.myapprc.json → { indent: 2, rules: { semi: "error" } }
// /.myapprc.json → { indent: 4, color: true, rules: { semi: "warn" } }
// result.config → { indent: 2, color: true, rules: { semi: "error" } }Use stopWhen for ESLint-style root: true cascading stops:
const result = await searchConfig(ctx, {
name: "myapp",
merge: true,
stopWhen: (cfg) => cfg.root === true,
});Also exports loadConfig for loading a known file path directly (e.g. when the user passes --config ./path), and findUp for locating files by name up the directory tree.
just-bash-util/path — Path utilities
Pure POSIX path operations with no Node.js dependency.
import {
join,
resolve,
dirname,
basename,
extname,
relative,
parse,
normalize,
parsePackageSpecifier,
} from "just-bash-util/path";
join("src", "utils", "index.ts"); // "src/utils/index.ts"
dirname("/project/src/index.ts"); // "/project/src"
basename("src/index.ts", ".ts"); // "index"
relative("/a/b/c", "/a/d"); // "../../d"
parsePackageSpecifier("@vue/shared/dist"); // { name: "@vue/shared", subpath: "./dist" }
parsePackageSpecifier("lodash/merge"); // { name: "lodash", subpath: "./merge" }join vs resolve — join concatenates segments and normalizes; an absolute second argument is kept as-is (appended, not replacing). resolve processes right-to-left and stops at the first absolute path, like Node's path.resolve (but without prepending cwd when no absolute segment exists):
join("/repo", "/file.txt"); // "/repo/file.txt" — concatenates with /
resolve("/repo", "/file.txt"); // "/file.txt" — absolute segment wins
resolve("/repo", "file.txt"); // "/repo/file.txt"
resolve("a", "b"); // "a/b" — stays relative (no cwd)Peer dependencies
Requires just-bash ^2.9.6 — provides the CommandContext and ExecResult types used throughout.
Status
This project is in early development. Test coverage exists but is limited — expect gaps, especially around edge cases. Contributions and bug reports are welcome.
Disclaimer
This project is not affiliated with, endorsed by, or associated with Vercel or the just-bash project.
License
MIT
