politty is a lightweight, type-safe CLI framework for Node.js built on Zod v4.
From simple scripts to complex CLI tools with subcommands, validation, and auto-generated help, you can build them all with a developer-friendly API.
- Zod Native: Use Zod schemas directly for argument definition and validation
- Type Safety: Full TypeScript support with automatic type inference for parsed arguments
- Flexible Argument Definition: Support for positional arguments, flags, aliases, arrays, and environment variable fallbacks
- Subcommands: Build Git-style nested subcommands (with lazy loading and alias support)
- Lifecycle Management: Guaranteed
setup→run→cleanupexecution order - Signal Handling: Proper SIGINT/SIGTERM handling with guaranteed cleanup execution
- Auto Help Generation: Automatically generate help text from definitions
- Interactive Prompts: Prompt for missing arguments with pluggable adapters (clack, inquirer)
- Discriminated Union: Support for mutually exclusive argument sets
- Skill Management: Manage agent skills (SKILL.md) with file-based install/uninstall
- Node.js >= 18
- Zod >= 4.2.1
npm install politty zod
# or
pnpm add politty zod
# or
yarn add politty zodimport { z } from "zod";
import { defineCommand, runMain, arg } from "politty";
const command = defineCommand({
name: "greet",
description: "A CLI tool that displays greetings",
args: z.object({
name: arg(z.string(), {
positional: true,
description: "Name of the person to greet",
}),
greeting: arg(z.string().default("Hello"), {
alias: "g",
description: "Greeting phrase",
}),
loud: arg(z.boolean().default(false), {
alias: "l",
description: "Output in uppercase",
}),
}),
run: (args) => {
let message = `${args.greeting}, ${args.name}!`;
if (args.loud) {
message = message.toUpperCase();
}
console.log(message);
},
});
runMain(command);Example usage:
$ my-cli World
Hello, World!
$ my-cli World -g "Hi" -l
HI, WORLD!
$ my-cli --help
Usage: greet <name> [options]
A CLI tool that displays greetings
Arguments:
name Name of the person to greet
Options:
-g, --greeting <value> Greeting phrase (default: "Hello")
-l, --loud Output in uppercase
-h, --help Show helpUse the arg() function to define argument metadata:
import { z } from "zod";
import { arg, defineCommand } from "politty";
const command = defineCommand({
name: "example",
args: z.object({
// Positional argument (required)
input: arg(z.string(), {
positional: true,
description: "Input file",
}),
// Optional positional argument
output: arg(z.string().optional(), {
positional: true,
description: "Output file",
}),
// Flag (with alias)
verbose: arg(z.boolean().default(false), {
alias: "v",
description: "Verbose output",
}),
// Environment variable fallback
apiKey: arg(z.string().optional(), {
env: "API_KEY",
description: "API key",
}),
// Array argument (--file a.txt --file b.txt)
files: arg(z.array(z.string()).default([]), {
alias: "f",
description: "Files to process",
}),
}),
run: (args) => {
console.log(args);
},
});Define Git-style subcommands:
import { z } from "zod";
import { arg, defineCommand, runMain } from "politty";
const initCommand = defineCommand({
name: "init",
description: "Initialize a project",
aliases: ["i"],
args: z.object({
template: arg(z.string().default("default"), {
alias: "t",
description: "Template name",
}),
}),
run: (args) => {
console.log(`Initializing with template: ${args.template}`);
},
});
const buildCommand = defineCommand({
name: "build",
description: "Build the project",
aliases: ["b"],
args: z.object({
output: arg(z.string().default("dist"), {
alias: "o",
description: "Output directory",
}),
minify: arg(z.boolean().default(false), {
alias: "m",
description: "Minify output",
}),
}),
run: (args) => {
console.log(`Building to: ${args.output}`);
},
});
const cli = defineCommand({
name: "my-cli",
description: "Example CLI with subcommands",
subCommands: {
init: initCommand,
build: buildCommand,
},
});
runMain(cli, { version: "1.0.0" });Example usage:
$ my-cli init -t react
$ my-cli i -t react # alias for init
$ my-cli build -o out -m
$ my-cli b -o out -m # alias for build
$ my-cli --helpExecute hooks in setup → run → cleanup order. The cleanup hook is always executed, even if an error occurs:
const command = defineCommand({
name: "db-query",
description: "Execute database queries",
args: z.object({
database: arg(z.string(), {
alias: "d",
description: "Database connection string",
}),
query: arg(z.string(), {
alias: "q",
description: "SQL query",
}),
}),
setup: async ({ args }) => {
console.log("[setup] Connecting to database...");
// Establish DB connection
},
run: async (args) => {
console.log("[run] Executing query...");
// Execute query
return { rowCount: 42 };
},
cleanup: async ({ args, error }) => {
console.log("[cleanup] Closing connection...");
if (error) {
console.error(`Error occurred: ${error.message}`);
}
// Close connection
},
});Define a command.
| Option | Type | Description |
|---|---|---|
name |
string |
Command name |
description |
string? |
Command description |
args |
ZodSchema |
Argument schema |
aliases |
string[]? |
Command aliases |
subCommands |
Record<string, Command>? |
Subcommands |
setup |
(context) => Promise<void>? |
Setup hook |
run |
(args) => T? |
Run function |
cleanup |
(context) => Promise<void>? |
Cleanup hook |
CLI entry point. Handles signals and calls process.exit().
runMain(command, {
version: "1.0.0", // Displayed with --version flag
argv: process.argv, // Custom argv
});Programmatic/testing entry point. Does not call process.exit() and returns a result object.
const result = await runCommand(command, ["arg1", "--flag"]);
if (result.success) {
console.log(result.result);
} else {
console.error(result.error);
}Attach metadata to an argument.
| Metadata | Type | Description |
|---|---|---|
positional |
boolean? |
Treat as positional argument |
alias |
string? |
Short alias (e.g., -v) |
description |
string? |
Argument description |
placeholder |
string? |
Placeholder shown in help |
env |
string? |
Environment variable name (fallback) |
completion |
object? |
Shell completion configuration |
prompt |
PromptMeta? |
Interactive prompt configuration (docs) |
politty provides automatic shell completion generation for bash, zsh, and fish.
The default generated script is a small runtime dispatcher: when the user
presses TAB, it resolves the executable currently visible on PATH and uses a
bundled static worker from that executable's package when one is available. If
no package-relative bundled worker is found, it falls back to a per-binary
static worker cache and regenerates that cache from the hidden
__refresh-completion command when needed. Sourced workers are memoized for the
shell session, so warm completions call the worker function directly.
Completion fields that require runtime JavaScript still delegate to the
binary's hidden __complete command.
When NODE_COMPILE_CACHE is unset, the dispatcher sets it to a
program-specific cache directory before invoking __complete, letting Node.js
22+ reuse V8 module compile cache across repeated completion requests.
Use withCompletionCommand to add completion support to your CLI:
import { defineCommand, runMain, withCompletionCommand } from "politty";
const mainCommand = withCompletionCommand(
defineCommand({
name: "mycli",
subCommands: {
build: buildCommand,
test: testCommand,
},
}),
);
runMain(mainCommand);Then users can enable completions:
# Bash
eval "$(mycli completion bash)"
# Zsh
eval "$(mycli completion zsh)"
# Fish
mycli completion fish | sourceFor project-local CLIs, put the local binary directory in PATH with your
environment manager, for example:
# .envrc
PATH_add node_modules/.binCompletion then follows the same executable the shell would run.
Published CLIs can ship a fast worker artifact:
politty generate-worker --bin dist/cli/index.mjs --program mycli --shell zsh --verifyor call generateBundledCompletionWorker() from politty/completion in your
own build script. The default output path is
dist/completion/<shell>-worker.<ext>.
The dispatcher looks for common package-relative worker paths such as
dist/completion/zsh-worker.zsh from the visible binary. Use
withCompletionCommand({ bundledWorker: { relativePaths: { zsh: [...] } } })
to customize the package-relative lookup. For package layouts that cannot be
expressed with relative paths, bundledWorker.queryCommand: true lets the
dispatcher ask the binary's hidden __completion-worker-path command on the
miss path.
To generate the older static script with command metadata baked in, use:
eval "$(mycli completion bash --static)"
eval "$(mycli completion zsh --static)"Define completion hints for arguments:
const command = defineCommand({
name: "build",
args: z.object({
// Auto-detected from z.enum()
format: arg(z.enum(["json", "yaml", "xml"]), {
alias: "f",
description: "Output format",
}),
// File completion
config: arg(z.string(), {
completion: { type: "file", extensions: ["json", "yaml"] },
}),
// Directory completion
outputDir: arg(z.string(), {
completion: { type: "directory" },
}),
// Custom shell command
branch: arg(z.string().optional(), {
completion: {
custom: { shellCommand: "git branch --format='%(refname:short)'" },
},
}),
// Static choices
environment: arg(z.string(), {
completion: {
custom: { choices: ["development", "staging", "production"] },
},
}),
}),
run: (args) => {
/* ... */
},
});politty manages SKILL.md-based agent skills distributed via npm packages.
Use withSkillCommand to add skill management to your CLI:
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineCommand, runMain } from "politty";
import { withSkillCommand } from "politty/skill";
// Resolves to ../skills from both src/ and dist/
const sourceDir = resolve(dirname(fileURLToPath(import.meta.url)), "../skills");
const cli = withSkillCommand(
defineCommand({
name: "my-agent",
subCommands: {
/* ... */
},
}),
{ sourceDir, package: "@my-agent/skills" },
);
runMain(cli);package identifies who owns these skills. It is combined with the command name as "{package}:{cliName}" and must match the metadata["politty-cli"] stamp pre-declared in each source SKILL.md — skills add/sync refuse mismatches, and remove/sync refuse to delete skills belonging to another tool. The default install mode is "symlink" (.agents/skills/<name> -> source, .claude/skills/<name> -> canonical), so source updates propagate live; on filesystems without symlink support (e.g. Windows without Developer Mode) install throws with guidance to retry with mode: "copy", which recursively copies instead (source updates then require re-running sync). See Skill Management for details.
Skills are SKILL.md files with YAML frontmatter (spec-compliant: https://agentskills.io/specification). The metadata["politty-cli"] stamp is authored by the skill package:
---
name: commit
description: Git commit message generation
license: MIT
metadata:
politty-cli: "@my-agent/skills:my-agent"
---
# Instructions for the agent...Then users can manage skills:
my-agent skills sync # Remove and reinstall all skills
my-agent skills add commit # Install a specific skill
my-agent skills remove commit # Remove a specific skill
my-agent skills list # List available skillsFor detailed documentation, see the docs/ directory:
- Getting Started - Installation and creating your first command
- Essentials - Core concepts explained
- Advanced Features - Subcommands, Discriminated Union
- Interactive Prompts - Prompt for missing arguments interactively
- Recipes - Testing, configuration, error handling
- Skill Management - Agent skill management (SKILL.md-based)
- API Reference - Detailed API reference
- Doc Generation - Automatic documentation generation
The playground/ directory contains many examples:
01-hello-world- Minimal command configuration02-greet- Positional arguments and flags03-array-args- Array arguments05-lifecycle-hooks- Lifecycle hooks10-subcommands- Subcommands12-discriminated-union- Discriminated Union21-lazy-subcommands- Lazy loading26-command-alias- Command aliases
MIT