Command Line Interface Framework in Task
A batteries-included, language-agnostic framework for building custom CLIs using go-task.
You write your command logic in any language — clift provides the UX.
More demos
Rich --help with groups, choices, deprecated markers, and completion install hint.
String, bool, int, list, and repeated flags all export as CLIFT_FLAG_* env vars.
choices: and pattern: fail fast before the user script runs.
Flat group schema: mutually exclusive (--json vs --yaml) and required-together (--user + --token).
Declared on the root Taskfile, accepted either before or after the command token.
Command aliases (dep → deploy), flag aliases (--env → --target), and one-shot deprecation warnings.
Typos get did-you-mean suggestions; wrong types and missing flags get clear, actionable errors.
- Quick Start
- Features
- Requirements
- How It Works
- Modes
- Creating Commands
- Argument Parsing
- Configuration
- Global Flags
- Logging
- Shell Completions
- Team Setup
- Updating
- Versioning
- cfgd Integration
- Example
- Error UX
- Development
- License
# Prerequisites: bash 4+, task (go-task), jq, yq
# Optional: gum (for pretty prompts)
# Clone the framework
git clone <repo-url> ~/.clift
export PATH="$HOME/.clift/bin:$PATH"
# Create a new CLI
clift init ~/mycli
# Source your shell and start using it
source ~/.bashrc
mycli # see available commands
mycli new cmd # scaffold your first command
mycli greet --name world # run it with flags
mycli greet --help # per-command help
mycli help greet # equivalent form — `help <cmd>` is an aliasAll clift init options are available as flags for non-interactive use:
clift init ~/mycli --name myapp --version 1.0.0 --theme brackets --mode standardOpt-in integrations are gated behind their own flags (nothing is generated by default):
clift init ~/mycli --cfgd # also generate module.yaml for cfgd versioning
clift init ~/mycli --ci # also generate .github/workflows/ci.yml (shellcheck)See clift help for the full list. You can also use task directly:
task --taskfile ~/.clift/Taskfile.yaml setup:cli -- ~/mycli- Cobra-style help system with grouped commands
- Themed logging (7 built-in themes + custom color schemes, honors
NO_COLOR) - Typed flag parsing (bool, string, int, list) with defaults, required flags, and short aliases
- Rich flag schema — long-name aliases, deprecated markers, hidden flags, mutually-exclusive and required-together groups, and value validation via
choices:/pattern: - Persistent (CLI-wide) flags that work before or after the command token
- Hidden commands (
vars.HIDDEN: true) — executable but omitted from help + completion - Command aliases (
aliases: [...]on tasks) — dispatch, help, completion, and did-you-mean all honor them - Override system — per-command and CLI-global tiers for
help_detail,version_print,log, and pre/post command hooks (top-levelhelp_listis CLI-global only) - Cache control —
--no-cacheflag andCLIFT_CACHE=rebuild|bypassenvironment variable (see docs/cache.md) - go-task runner flags as first-class UX —
mycli watch,--task:dry,--task:interval,--task:parallel, … - Did-you-mean error suggestions (Levenshtein)
- Interactive prompts (gum with read fallback)
- Config management (get/set/show/edit/theme)
- Command scaffolding with
new cmd - Global flags:
--verbose,--quiet,--no-color,--help,--version - Shell completions (bash, zsh) — static + dynamic flag-value completers, completion-install hint in
mycli --help, setup-time installer - Framework self-update; optional versioning and distribution via cfgd
| Dependency | Required | Purpose |
|---|---|---|
| bash 4.2+ | Yes | Associative arrays, declare -g, ${var^^}, used throughout |
| Task v3.0+ | Yes | Task runner that powers the CLI |
| jq | Yes | JSON processing for help and config |
| yq | Yes | YAML processing for metadata |
| gum | No | Enhanced interactive prompts (falls back to read) |
macOS note: macOS ships bash 3.2. Install bash 4.2+ via
brew install bash.
clift is a framework repo that provides shared libraries (lib/) for help, logging, routing, argument parsing, config, and more. When you bootstrap a CLI with setup:cli, it creates a project directory with:
~/.config/mycli/
.env # CLI_NAME, CLI_VERSION, FRAMEWORK_DIR, LOG_THEME
Taskfile.yaml # includes framework libs + your commands
cmds/
greet/
Taskfile.yaml # task definition (desc, routing, help)
greet.{sh,py,go,rs,…} # your command logic, any language
In standard mode, your CLI is a wrapper script on PATH that provides Cobra-style mycli cmd subcmd --flag UX. In task mode, it's a shell alias that invokes task directly. The framework's router handles global flags, logging setup, and dispatching to your command scripts. Commands are Taskfile includes -- each command lives in cmds/<name>/ with its own Taskfile and script.
clift CLIs support two argument-format styles, chosen at setup time:
- Standard mode --
mycli cmd subcmd --flag value(Cobra-like, recommended) - Task mode --
mycli cmd:subcmd -- --flag value(raw go-task semantics)
Standard mode gives you space-separated subcommands, --flag parsing, did-you-mean errors, and shell completions with flag support. See docs/modes.md.
mycli new cmd
# Prompts for: command name, short description
# Generates: cmds/<name>/Taskfile.yaml + <name>.shThe generated script comes pre-wired with logging and the flag env var contract:
#!/usr/bin/env bash
set -euo pipefail
source "${FRAMEWORK_DIR}/lib/log/log.sh"
# Flag values arrive as CLIFT_FLAG_<NAME> (uppercased, dashes→underscores).
# Positional args: CLIFT_POS_1, CLIFT_POS_2, ... CLIFT_POS_COUNT.
log_info "Hello from mycommand"Subcommands work via task namespacing. A command deploy can have subcommands deploy:staging and deploy:prod by adding tasks to its Taskfile.
Commands with no vars.FLAGS key in their Taskfile are passthrough commands. The router skips the parser entirely and execs your script with raw positional args ($1, $2, …). No CLIFT_FLAG_* env vars, no --help interception, no did-you-mean.
This is a first-class choice — use it when:
- The script is written in another language (Go, Python, Rust) with its own argument parsing and you don't want clift's parser to second-guess it.
- The command wraps an external tool transparently (
mycli docker …→ forward everything todocker). - You want to own flag handling end-to-end for a niche UX.
vars.FLAGS: [] (empty list) is parsed mode — it gets the parser, --help, and globals, just with no per-command flags. Omit the key entirely to go passthrough. See docs/scripts.md for the full contract (persistent flags still bind; pre/post hooks still fire).
Declare flags in your command's Taskfile.yaml under vars.FLAGS. The router parses them before your script runs and exports them as env vars:
# cmds/deploy/Taskfile.yaml
vars:
FLAGS:
- {name: target, short: t, type: string, default: staging, desc: "Target env"}
- {name: force, short: f, type: bool, desc: "Skip confirmation"}# cmds/deploy/deploy.sh
target="${CLIFT_FLAG_TARGET}" # "staging" (default applied)
if [[ "${CLIFT_FLAG_FORCE:-}" == "true" ]]; then ...
file="${CLIFT_POS_1:?missing file}" # positional argsSee docs/flags.md for the full schema, docs/scripts.md for the env var contract, and docs/cache.md for how --no-cache / CLIFT_CACHE=rebuild|bypass control the compiled .clift/ cache.
mycli config show # display all config
mycli config get LOG_THEME # get a single value
mycli config set LOG_THEME brackets-color
mycli config theme # interactive theme picker
mycli config edit # open .env in $EDITORConfiguration lives in .env at the CLI project root. Key variables:
| Variable | Description |
|---|---|
CLI_NAME |
Name of your CLI |
CLI_VERSION |
Version string |
FRAMEWORK_DIR |
Path to the clift framework |
CLI_DIR |
Path to your CLI project |
LOG_THEME |
Active logging theme |
These flags are handled by the router and available to all commands:
| Flag | Short | Description |
|---|---|---|
--help |
-h |
Show help for the current command |
--version |
-V |
Print CLI version |
--verbose |
-v |
Enable debug log output |
--quiet |
-q |
Suppress info/success messages |
--no-color |
Disable colored output |
The NO_COLOR environment variable is also respected per no-color.org.
Seven built-in themes control how log messages are formatted:
| Theme | Example output |
|---|---|
icons |
→ message |
icons-color |
→ message (colored) |
brackets |
[INFO] message |
brackets-color |
[INFO] message (colored) |
minimal |
message |
minimal-color |
message (colored) |
custom |
User-defined via LOG_FMT_* vars |
Log functions available in your scripts after sourcing log.sh:
log_info "informational"
log_warn "warning"
log_error "something broke"
log_success "done"
log_debug "only shown with --verbose"
log_suggest "hint text (dimmed, suppressed by --quiet)"
die "fatal error" 1
clift_exit 2 "invalid arg" # log+exit helper; prefer over die+exit in new scriptsFor the custom theme, define format strings in your .env:
LOG_FMT_INFO=":: %s"
LOG_FMT_WARN="!! %s"
LOG_FMT_ERROR="** %s"
LOG_FMT_SUCCESS="++ %s"
LOG_FMT_DEBUG=".. %s"Any theme's colors can be overridden via LOG_CLR_* env vars in .env. Values are ANSI escape sequences.
| Variable | Controls | Default |
|---|---|---|
LOG_CLR_INFO |
Info messages | Blue (\033[0;34m) |
LOG_CLR_WARN |
Warnings | Yellow (\033[0;33m) |
LOG_CLR_ERROR |
Errors | Red (\033[0;31m) |
LOG_CLR_SUCCESS |
Success messages | Green (\033[0;32m) |
LOG_CLR_DEBUG |
Debug messages | Cyan (\033[0;36m) |
LOG_CLR_DIM |
Suggestions | Dim (\033[2m) |
Example: Dracula color scheme
# .env — Dracula palette over the default icons-color theme
LOG_CLR_INFO=\033[38;2;139;233;253m
LOG_CLR_WARN=\033[38;2;255;184;108m
LOG_CLR_ERROR=\033[38;2;255;85;85m
LOG_CLR_SUCCESS=\033[38;2;80;250;123m
LOG_CLR_DEBUG=\033[38;2;189;147;249m
LOG_CLR_DIM=\033[38;2;98;114;164mExample: Catppuccin Mocha
LOG_CLR_INFO=\033[38;2;137;180;250m
LOG_CLR_WARN=\033[38;2;249;226;175m
LOG_CLR_ERROR=\033[38;2;243;139;168m
LOG_CLR_SUCCESS=\033[38;2;166;227;161m
LOG_CLR_DEBUG=\033[38;2;203;166;247m
LOG_CLR_DIM=\033[38;2;108;112;134mBash and zsh completion is wired automatically at setup:cli time and again when the framework is updated. The generated script is cache-derived, so subcommand and flag candidates stay in sync with your Taskfiles without a rebuild step.
# Bash (add to ~/.bashrc — setup:cli does this for you)
eval "$(mycli completion bash)"
# Zsh (add to ~/.zshrc — setup:cli does this for you)
eval "$(mycli completion zsh)"mycli --help always prints a one-line install hint so new users can't miss it.
For flag values that are only known at runtime (regions, kube contexts, branches), drop a clift_complete_<task>_<flag> function into $CLI_DIR/.clift/overrides/completion.sh (or per-command under cmds/<cmd>/overrides/completion.sh) and clift wires it automatically — no schema change.
See docs/cli/completion.md for the setup-time installer, static-candidate rules, and dynamic flag-value completers.
When sharing a CLI with your team, include the .clift.yaml file in your CLI's directory. It documents what dependencies your CLI needs:
name: my-team-cli
version: 1.0.0
description: "Internal deployment tools"
dependencies:
required:
- jq
- kubectl
optional:
- gum
- fzfTeammates can read this file to know what to install before using the CLI.
mycli update # pulls latest framework from gitclift CLIs can be versioned and distributed via cfgd. Versioning is opt-in — enable it during setup or add it later.
clift init ~/my-cli --cfgd
# or, equivalently:
CFGD_VERSIONING=true clift init ~/my-cliThis installs cfgd (if missing), configures the CLI as a cfgd module, and adds the version:* commands.
task --taskfile ~/.clift/Taskfile.yaml setup:versioning -- ~/my-cliOr from within the CLI itself (if version namespace is manually included):
mycli version setupOnce versioning is enabled, these commands are available:
| Command | Description |
|---|---|
version |
Show current version and cfgd status |
version setup |
Set up cfgd versioning (installs cfgd if needed) |
version upgrade |
Upgrade to the latest version via cfgd |
version update |
Alias for version upgrade |
version set <ver> |
Pin to a specific version (e.g., v1.2.3) |
By default, version setup treats the CLI as a standalone module in its own git repo. To add it to an existing cfgd config repo instead, set these environment variables:
| Variable | Description |
|---|---|
CFGD_CONFIG_DIR |
Path to your cfgd config repo (e.g., ~/dotfiles) |
CFGD_PROFILES |
Comma-separated profiles to add the module to (e.g., dev,work) |
CFGD_CONFIG_DIR=~/dotfiles CFGD_PROFILES=dev mycli version setupAfter versioning is set up, tag releases using cfgd's convention:
git tag "mycli/v1.0.0"
git push origin --tagsConsumers upgrade with mycli version upgrade or pin with mycli version set v1.0.0.
cfgd is a declarative machine configuration tool. When available, clift uses it as a backend for dependency management, updates, and drift detection. cfgd is never required. Everything works without it.
To manage the clift framework with cfgd, copy cfgd/clift/module.yaml into your cfgd config's modules/ directory:
cp ~/.clift/cfgd/clift/module.yaml ~/dotfiles/modules/clift/module.yamlThis module declares go-task, jq, yq, and gum as packages and clones the framework repo. Add it to your profile:
# profiles/work.yaml
spec:
modules:
- cliftThen cfgd apply installs everything.
When you bootstrap a CLI with CFGD_VERSIONING=true clift init ..., a module.yaml is generated alongside it. This module depends on clift, declares your CLI's dependencies from .clift.yaml, and configures the shell alias. Copy it to your cfgd config to distribute the CLI to other machines or teammates. Without CFGD_VERSIONING=true, no module.yaml is created.
When cfgd manages your framework installation, mycli update detects this and directs you to use cfgd instead. How updates work depends on how you pin the module:
| Pin style | module.yaml source | How to update | What you get |
|---|---|---|---|
| Tag | ...git@v0.2.0 |
cfgd module upgrade clift --ref v0.3.0 |
Explicit version bumps |
| Latest | (any) | cfgd module upgrade clift |
Bumps lockfile to repo HEAD |
Both styles lock to a specific commit SHA in modules.lock. Between upgrades, the version never changes — even if new commits are pushed upstream.
If the cfgd daemon is enabled, it periodically verifies that managed installations match their lockfile pin:
- Dependency healing — if
jqorgumgets uninstalled, the daemon reinstalls them on the next reconcile cycle - File protection — if framework files are accidentally modified, the daemon restores them to the pinned state
- No surprise updates — the daemon enforces the current pin, it does not pull new versions. Updates are always explicit via
cfgd module upgrade
What happens on drift depends on your driftPolicy (Auto, NotifyOnly, or Prompt) — see cfgd docs.
If cfgd is not installed, nothing changes:
deps.shchecks forjqandyq(required) andgum(optional)mycli updateusesgit pullas before.clift.yamldocuments dependencies for humans to install manually
See examples/bm/ — a small bookmark manager (5 commands, ~575 LOC bash) that demonstrates the framework's primary surface end-to-end. It's deliberately small enough to read in one sitting.
$ bm add https://taskfile.dev --name task --tag rust --tag build
✓ task
$ bm list --tag rust --format json
[
{
"name": "task",
"url": "https://taskfile.dev",
"description": "",
"tags": ["rust", "build"],
"added_at": "2026-05-06T13:21:53Z"
}
]
$ bm --profile work add https://example.com --name w
✓ w
$ bm --profile home list # isolated; empty
NAME URL TAGS AGEFeatures exercised:
- 5 commands (
add,list,open,rm,tag) with subcommand alias (ls→list) - String, bool, int, list, choices, pattern flags
- Mutex group on
tag --addvs--remove - Persistent
--profileflag (per-profile store isolation) - Dynamic completion (
bm open <TAB>enumerates the active store)
For a full production-grade reference example exercising the entire framework surface — multi-language native helpers (Go, Rust, Python), pluggable integrations (calendar providers, notification channels), profile-aware state, NDJSON contracts — see tj-smith47/jarvis.
clift provides production-grade error messages out of the box — no code required:
$ bm lstt
error: unknown command 'lstt'
did you mean 'list'?
run 'bm --help' for commands
$ bm list --format bogus
error: flag '--format' requires one of: table, json, yaml, got 'bogus'
$ bm tag mybook --add foo --remove bar
error: flags '--add', '--remove' in group 'mutation' are mutually exclusive
The full reference example tj-smith47/jarvis demonstrates more elaborate scenarios — pattern-validated flags, required-together groups, deprecated-flag warnings:
$ jarvis remind 'standup' --in 5
error: flag '--in' requires value matching pattern '^[0-9]+[smhd]$', got '5'
$ jarvis remind 'standup' --in 10m --at 13:00
error: flags '--in', '--at' in group 'when' are mutually exclusive
bats tests/*.bats # full suite — see CI for current count
scripts/coverage.sh # line coverage via kcov (79%+)
scripts/benchmark.sh # overhead benchmark (~50ms)
shellcheck lib/**/*.sh # lintDemo GIFs in the README are recorded with VHS. Tape files live in .vhs/, GIFs are output to .vhs/gifs/.
# Install VHS (requires Go 1.22+)
go install github.com/charmbracelet/vhs@latest
# Record all demos (requires a TTY)
scripts/record-demos.sh
# Or record a single tape
vhs .vhs/hero.tapeAfter recording, commit the updated GIFs so they render on GitHub.
MIT -- see LICENSE.
