A framework for building production-ready CLI apps and Terminal UIs with minimal code.
Project description
clidev
clidev is a Python framework for building production-ready Command Line Applications (CLI) and Terminal User Interfaces (TUI) with minimal code.
Unlike traditional CLI libraries that only parse arguments, clidev gives you
a complete framework for interactive terminal apps — menus, forms, workflows,
dashboards, state management, command execution, navigation, and
event-driven logic — so you don't have to hand-write input loops, menu
rendering, or terminal state machines.
Think of it as the Flutter/React of terminal applications: you describe what your app looks like and does,
clidevhandles the rendering loop.
Built on top of rich for terminal
rendering and questionary for
interactive prompts.
Installation
pip install -e .
For image/video rendering (local files + URLs):
pip install -e ".[media]"
For audio extraction without a system ffmpeg install:
pip install -e ".[audio-extract]"
Quick start
from clidev import App
app = App("My App")
home = app.menu("Home")
home.option("Say Hello", lambda: app.success("Hello!"))
home.option("Exit", app.exit)
app.run()
Or scaffold a brand-new project with the bundled CLI:
clidev new myproject
cd myproject
python app.py
Module guide
App (clidev/app.py)
The core object. Wires together state, storage, routing, events, plugins, theming, and every UI widget factory.
from clidev import App
app = App("Developer Toolkit", theme="dark", storage_backend="json")
State (clidev/state.py)
Global, dict-like state, accessible anywhere in your app.
app.state["username"] = "Vrushabh"
print(app.state["username"])
app.state.on_change(lambda key, old, new: print(f"{key}: {old} -> {new}"))
Storage (clidev/storage.py)
Every form (or any code) can persist data through a pluggable backend:
memory, json, sqlite, yaml, or toml.
app.storage.save("user", {"name": "Bob"})
user = app.storage.load("user")
app = App("My App", storage_backend="sqlite")
Forms (clidev/forms.py, inputs.py, validators.py)
Chainable form builder with automatic validation.
form = app.form("User")
form.text("Name")
form.email("Email")
form.password("Password")
form.number("Age")
data = form.run()
# {"Name": "Vrushabh", "Email": "abc@gmail.com", "Password": "******", "Age": 17}
Supported field types: text, email, password, number, url, file,
folder, date, time, checkbox, toggle, radio, select/dropdown,
multiselect, searchable.
Custom validators can be attached per-field via extra_validators=[...]
using anything from clidev.validators (min_length, max_length,
min_value, max_value, is_date, etc.).
Menus (clidev/menus.py)
menu = app.menu("Main Menu")
menu.option("Create Project", create_project)
menu.option("Deploy", deploy)
menu.option("Exit", app.exit)
Nested menus:
main = app.menu("Main")
settings = app.menu("Settings")
main.link("Settings", settings)
Page routing (clidev/router.py, pages.py)
@app.page("home")
def home():
...
app.goto("settings")
app.back()
Conditional navigation (clidev/actions.py)
@app.when(lambda data: data["Role"] == "Admin")
def admin():
app.goto("admin_menu")
or:
app.if_value("Role", equals="Admin").goto("admin_menu")
app.if_value("Age", greater_than=18).goto("adult_menu")
Supported comparisons: equals, not_equals, greater_than, less_than,
greater_equal, less_equal, contains, in_list.
Workflow engine (clidev/workflow.py)
workflow = app.workflow()
workflow.step(login)
workflow.step(select_project)
workflow.step(build)
workflow.step(deploy)
result = workflow.start()
Each step can accept a shared context dict; whatever a step returns (as a
dict) is merged into that context for subsequent steps.
Events (clidev/events.py)
@app.on_start
def startup():
...
@app.on_exit
def shutdown():
...
@app.on_submit(some_form)
def save(data):
...
@app.on_error
def on_error(e):
...
``
### Images & Video (`clidev/image.py`)
Render static images and play video clips directly inside the terminal,
using colored half-block characters (real truecolor, not ASCII-art
approximation) for near photo-quality output in supporting terminals.
```python
app.image("photo.png").show()
app.video("clip.mp4").play()
Requires optional dependencies:
pip install clidev[media]
or individually:
pip install Pillow # for images
pip install opencv-python # for video
If these aren't installed, calling .image() or .video() raises a
clear ClidevError telling you what to install — the rest of clidev
works fine without them.
Images & Video (clidev/image.py)
Render static images and play video clips directly inside the terminal, using colored half-block characters (real truecolor, not ASCII-art approximation) for near photo-quality output in supporting terminals. Sources can be a local file, a direct URL, or a Google Drive share link — and video playback can include synced audio.
app.image("photo.png").show()
app.video("clip.mp4").play()
Requires optional dependencies:
pip install clidevkit[media]
or individually:
pip install Pillow # for images
pip install opencv-python # for video
pip install requests # for loading from a URL or Google Drive
pip install pygame # for audio playback
If a dependency needed for what you're calling isn't installed,
.image() / .video() raise a clear ClidevError telling you exactly
what to install — the rest of clidev works fine without them.
Images
app.image("photo.png").show()
app.image("photo.png", size="small").show() # 40 columns wide
app.image("photo.png", size="medium").show() # 80 columns wide (default)
app.image("photo.png", size="large").show() # 120 columns wide
app.image("photo.png", width=60).show() # explicit width overrides size
| Argument | Default | Description |
|---|---|---|
path |
— | Local file path, direct URL, or Google Drive share link |
size |
"medium" |
"small" (40 cols), "medium" (80 cols), or "large" (120 cols) |
width |
None |
Explicit width in terminal columns; overrides size if set |
Video
app.video("clip.mp4").play() # 24 fps, silent by default
app.video("clip.mp4", audio=True).play() # with synced sound
app.video("clip.mp4", size="small").play() # smaller renders faster
app.video("clip.mp4", fps=30).play(max_frames=200)
| Argument | Default | Description |
|---|---|---|
path |
— | Local file path, direct URL, or Google Drive share link |
size |
"medium" |
"small" (40 cols), "medium" (80 cols), or "large" (100 cols) |
width |
None |
Explicit width in terminal columns; overrides size if set |
fps |
24 |
Target playback frame rate |
audio |
False |
Extract and play the video's audio track, synced to playback |
.play() options:
max_frames— stop after N rendered frames (default: play whole video)skip— render every Nth source frame; increase this if the source video's native fps is much higher than your target fps
Playback accuracy: frame timing is measured with a monotonic clock
and accounts for how long each frame actually took to render, so
playback holds close to your target fps instead of gradually drifting
slower the longer the clip plays. Smaller size values render faster
per frame, which matters if you're trying to hit a full 24fps on a
slower machine or a wide terminal.
Loading from a URL or Google Drive
app.image("https://example.com/photo.png").show()
app.image("https://drive.google.com/file/d/FILE_ID/view?usp=sharing").show()
app.video("https://example.com/clip.mp4", audio=True).play()
app.video("https://drive.google.com/file/d/FILE_ID/view", audio=True).play()
Direct URLs and Google Drive share links are both detected automatically
— no different method or flag needed, just pass the link as path.
Files are downloaded to a temporary location, used for rendering/playback,
and cleaned up automatically afterward. Google Drive's "file too large to
scan for viruses" confirmation step for big files is handled automatically.
Requires the requests package (pip install requests).
Audio playback
app.video("clip.mp4", audio=True).play()
Audio is extracted from the video's own audio track and played back in
sync, starting right alongside the first rendered frame. Extraction uses
moviepy if installed, falling back to a system ffmpeg binary on PATH
if not. If neither is available, or the source has no audio track,
playback continues silently rather than raising an error — audio is
best-effort, not a hard requirement for .play() to work.
# Optional, for audio extraction without a system ffmpeg install:
pip install moviepy
Terminal compatibility: works best in terminals with truecolor support
(Windows Terminal, iTerm2, most modern Linux terminals). Classic cmd.exe
may render duller or incorrect colors.
Command execution (clidev/shell.py)
app.cmd("git init")
result = app.cmd("git status", capture=True)
print(result.stdout, result.ok)
app.cmd("pip install -r requirements.txt", background=True)
Progress & tasks (clidev/progress.py, spinner.py, tasks.py, scheduler.py)
with app.progress("Installing"):
app.cmd("pip install numpy")
app.cmd("pip install pandas")
@app.task
def build():
...
app.run_task("build")
Plugins (clidev/plugins.py)
Plugins are reusable units of behavior that hook into an app's lifecycle
(startup, shutdown, page navigation, form submission, shell commands,
errors) without wiring that logic into every app you build. Write a
plugin once, drop it into any clidev app with app.use(...).
Basic usage
from clidev.plugins import Plugin
class GitPlugin(Plugin):
name = "git"
def on_install(self, app):
# Fires immediately when app.use(GitPlugin()) runs
result = app.cmd("git rev-parse --is-inside-work-tree", capture=True)
app.state["git_repo"] = result.ok
def on_start(self, app):
# Fires when app.run() is called
if app.state.get("git_repo"):
app.logger.info("Git repository detected.")
def on_exit(self, app):
# Fires when the app exits
app.logger.info("Goodbye from GitPlugin!")
app = App("My App")
app.use(GitPlugin())
app.run()
Available lifecycle hooks
Override only the ones you need — every hook is optional.
| Hook | Fires when |
|---|---|
on_install(app) |
Immediately, when app.use(plugin) runs |
on_start(app) |
The app starts (app.run()) |
on_page(app, page_name) |
Every page navigation (app.goto(name)) |
on_submit(app, form_title, data) |
Every form submission |
on_command(app, command, result) |
After every shell command (app.cmd(...)) |
on_error(app, error) |
Whenever an error is emitted through app.events |
on_exit(app) |
The app exits |
You're not limited to this list — any method named on_<something>
is automatically a valid hook. Apps and plugins can fire their own custom
events too:
app.plugins.dispatch("on_deploy_finished", environment="production")
and any installed plugin defining on_deploy_finished(self, app, environment)
will receive it.
Ordering with priority
Plugins run in ascending priority order (lower = earlier), with
registration order breaking ties.
class LoggingPlugin(Plugin):
name = "logging"
priority = 0 # runs before everything else
class AnalyticsPlugin(Plugin):
name = "analytics"
priority = 50 # runs after LoggingPlugin
Passing configuration
Plugins accept config as keyword arguments at registration time, and
read it back with self.get(...):
class AnalyticsPlugin(Plugin):
name = "analytics"
def on_start(self, app):
app.logger.info(f"Using API key: {self.get('api_key')}")
app.use(AnalyticsPlugin(), api_key="abc123")
Declaring dependencies between plugins
class AnalyticsPlugin(Plugin):
name = "analytics"
requires = ["auth"] # must be installed before this plugin
app.use(AuthPlugin())
app.use(AnalyticsPlugin()) # fine, "auth" is already installed
app.use(AnalyticsPlugin()) # raises PluginError if "auth" wasn't installed first
Reusing plugins across apps with the global registry
Register a plugin class once, then any app can pull it in by name instead of importing the class directly:
app.plugins.register_class("git", GitPlugin)
app.plugins.register_class("analytics", AnalyticsPlugin)
app.use("git")
app.use("analytics", api_key="abc123")
app.plugins.available() # ["git", "analytics"]
Quick one-off plugins without a class
from clidev.plugins import FunctionalPlugin
def greet(app):
app.logger.info("Hello from a functional plugin!")
app.use(FunctionalPlugin("greeter", on_start=greet))
Managing installed plugins
app.plugins.has("git") # True/False
app.plugins.get("git") # the installed Plugin instance, or None
app.plugins.disable("git") # stop it from receiving hooks (kept installed)
app.plugins.enable("git") # re-enable it
app.plugins.remove("git") # permanently uninstall it
app.plugins.list_plugins() # [{"name": ..., "version": ..., "priority": ..., ...}, ...]
Error handling
By default, if one plugin's hook raises an exception, it's logged (via
app.logger if available) and every other plugin still runs — one
broken plugin won't take down the rest of your app.
app.plugins.strict = True # opt back into raise-immediately behavior
With strict=True, a hook error re-raises immediately as a PluginError
and aborts the rest of that dispatch — useful during development when you
want failures to be loud.
Built-in example plugins
clidev/builtins/plugins.py ships two reference plugins you can use as-is
or copy as a starting point:
from clidev.builtins.plugins import GitPlugin, DatabasePlugin
app.use(GitPlugin()) # detects whether the cwd is a git repo
app.use(DatabasePlugin()) # confirms the storage backend is ready on start
Themes (clidev/themes.py, colors.py)
app = App("My App", theme="dark")
theme = Theme()
theme.primary("blue")
theme.success("green")
Banner (clidev/banner.py)
Render text as large ASCII-art block letters — great for app splash screens, section headers, or command output.
app.banner("HI").show()
Customize the symbol used to draw the letters:
app.banner("DEPLOYED", symbol="@").show()
Or use it standalone (outside an App instance):
from clidev.banner import Banner
Banner("HELLO", symbol="*").show()
Supported characters: A-Z (case-insensitive), 0-9, and basic
punctuation (. , ! ? - :). Unsupported characters render as blank space
rather than raising an error.
Constructor options:
| Argument | Default | Description |
|---|---|---|
text |
— | Text to render (auto-uppercased) |
symbol |
"#" |
Character used to draw filled pixels |
spacing |
1 |
Blank columns between letters |
theme |
None |
If set (e.g. via app.banner(...)), colors the banner using the theme's primary color |
.render() returns the raw multi-line string (useful if you want to embed
it inside a Card, log it, or write it to a file) — .show() prints it
directly to the console with theme styling applied.
from clidev import App
app = App("My App") app.banner("MY APP").show() ...
Logging (clidev/logger.py)
app.logger.info("Started")
app.logger.warning("Warning")
app.logger.error("Failed")
UI widgets
app.table(title, columns=[...])— data tables (clidev/table.py)app.tree(label)— tree views (clidev/tree.py)app.card(title, content)— bordered content cards (clidev/cards.py)app.dashboard(title)— multi-panel grid overview (clidev/dashboard.py)app.dialog.confirm(...),app.dialog.prompt(...),app.dialog.alert(...)(clidev/dialogs.py)app.notify.success/error/warning/info(...)(clidev/notifications.py)app.statusbar.set(...).render()(clidev/statusbar.py)
Project generator (clidev/generators/, cli.py)
clidev new myproject
Creates:
myproject/
│
├── app.py
├── routes.py
├── menus.py
├── forms.py
├── workflows.py
├── commands.py
├── storage.py
├── settings.py
└── assets/
Clidev full example: "DevOps Toolkit"
Demonstrates nearly every feature covered in the README:
- App setup with a theme
- Banner splash screen
- Menus (with nested submenu)
- Forms with validation
- Storage (persists project data to JSON)
- Page routing
- Conditional navigation (if_value / when)
- Workflow engine (multi-step deploy pipeline)
- Events (on_start / on_exit / on_submit)
- Shell command execution + progress bar
- Plugins (registry, config, dependencies)
- Tables, cards, dashboard`
- Image/video rendering (optional, only if deps installed)`
Run with: python examples/full_example.py
from clidev import App
from clidev.plugins import Plugin, FunctionalPlugin
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = App("DevOps Toolkit", theme="dark", storage_backend="json")
# ---------------------------------------------------------------------------
# Plugins
# ---------------------------------------------------------------------------
class GitPlugin(Plugin):
name = "git"
priority = 0
def on_install(self, app):
result = app.cmd("git rev-parse --is-inside-work-tree", capture=True)
app.state["git_repo"] = result.ok
def on_start(self, app):
if app.state.get("git_repo"):
app.logger.info("Git repository detected.")
else:
app.logger.warning("Not inside a git repository.")
class AnalyticsPlugin(Plugin):
name = "analytics"
priority = 50
requires = ["git"] # must be installed after GitPlugin
def on_start(self, app):
app.logger.info(f"Analytics session started (env: {self.get('env', 'dev')})")
def on_submit(self, app, form_title, data):
app.logger.info(f"[analytics] form submitted: {form_title}")
def on_exit(self, app):
app.logger.info("Analytics session ended.")
# Register once globally, reusable by name in any app
app.plugins.register_class("git", GitPlugin)
app.plugins.register_class("analytics", AnalyticsPlugin)
app.use("git")
app.use("analytics", env="production")
# Quick one-off plugin without a class
def greet(app):
app.logger.info("Welcome to the DevOps Toolkit!")
app.use(FunctionalPlugin("greeter", on_start=greet))
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@app.on_start
def startup():
app.banner("DEVOPS", symbol="#").show()
@app.on_exit
def shutdown():
app.info("Session ended. See you next time!")
@app.on_error
def handle_error(e):
app.error(f"Something went wrong: {e}")
# ---------------------------------------------------------------------------
# Pages
# ---------------------------------------------------------------------------
@app.page("dashboard")
def dashboard_page():
projects = app.storage.load("projects", default=[])
dash = app.dashboard("Toolkit Overview")
dash.panel("Projects", str(len(projects)))
dash.panel("Git Repo", "Yes" if app.state.get("git_repo") else "No")
dash.panel("Last Deploy", app.state.get("last_deploy", "never"))
dash.show()
if projects:
table = app.table("Registered Projects", columns=["Name", "Language"])
for p in projects:
table.add_row(p["Project Name"], p["Language"])
table.show()
@app.page("create_project")
def create_project_page():
project_form = app.form("New Project")
project_form.text("Project Name")
project_form.select("Language", ["Python", "Rust", "Go"])
project_form.checkbox("Use Docker")
data = project_form.run()
if not data:
return # user cancelled (Ctrl+C)
app._last_form_data = data
projects = app.storage.load("projects", default=[])
projects.append(data)
app.storage.save("projects", projects)
app.success(f"Project '{data['Project Name']}' created.")
# Conditional navigation based on form answers
app.if_value("Language", equals="Python").goto("python_setup")
app.if_value("Use Docker", equals=True).then(
lambda: app.info("Docker setup will run on first deploy.")
)
@app.page("python_setup")
def python_setup_page():
with app.progress("Setting up Python environment"):
app.cmd("python -m venv .venv_demo")
app.cmd("echo done")
app.success("Python environment ready.")
@app.page("deploy")
def deploy_page():
pipeline = app.workflow("deploy_pipeline")
def build(context):
app.info("Building project...")
result = app.cmd("echo Build complete", capture=True)
return {"build_output": result.stdout.strip()}
def test(context):
app.info("Running tests...")
return {"tests_passed": True}
def deploy(context):
if not context.get("tests_passed"):
raise RuntimeError("Cannot deploy: tests failed")
app.success(f"Deployed! ({context['build_output']})")
return {"deployed": True}
pipeline.step(build).step(test).step(deploy)
with app.spinner("Running deploy pipeline..."):
result = pipeline.start()
if result.get("deployed"):
import datetime
app.state["last_deploy"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
@app.page("media_demo")
def media_demo_page():
card = app.card(
"Media Demo",
"This page shows image/video rendering.\n"
"Requires: pip install clidevkit[media]"
)
card.show()
try:
# Local file, URL, or Google Drive link all work here.
app.image("assets/logo.png", size="medium").show()
except Exception as e:
app.warning(f"Skipping image demo: {e}")
# ---------------------------------------------------------------------------
# Menus (with a nested submenu)
# ---------------------------------------------------------------------------
main_menu = app.menu("DevOps Toolkit - Main Menu")
main_menu.option("Dashboard", "dashboard")
main_menu.option("Create Project", "create_project")
main_menu.option("Deploy", "deploy")
main_menu.option("Media Demo", "media_demo")
settings_menu = app.menu("Settings")
settings_menu.option("Show Plugins", lambda: app.console.print(app.plugins.list_plugins()))
settings_menu.option("Back", app.back)
main_menu.link("Settings", settings_menu)
main_menu.option("Exit", app.exit)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app.run()
```python
---
## Running the tests
```bash
pip install -e ".[dev]"
pytest tests/ -v
The test suite (in tests/) covers global state, all storage backends, form
field validation, the shell command wrapper, routing/history, the workflow
engine, conditional navigation, the event dispatcher, theming, the project
generator, and full App integration — 85 tests in total.
Roadmap
- Additional storage backends: PostgreSQL, MongoDB, Redis
clidev-auth,clidev-cloud,clidev-testing,clidev-pluginsas separate installable packages- Richer dashboard layout options and live-updating widgets
License
All Rights Reserved
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file clidevkit-1.2.3.tar.gz.
File metadata
- Download URL: clidevkit-1.2.3.tar.gz
- Upload date:
- Size: 52.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f366c990293684ed723ccabf8111b572a1fe9229b924a3d9d8aacdc43a05f535
|
|
| MD5 |
e842980c013b5dbf0d933cb0ec6aaf90
|
|
| BLAKE2b-256 |
fce585ea9bc4f4dfe7e990418e09191d72504e683a8c4d5cd7ac4f474e3d0744
|
File details
Details for the file clidevkit-1.2.3-py3-none-any.whl.
File metadata
- Download URL: clidevkit-1.2.3-py3-none-any.whl
- Upload date:
- Size: 46.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4e683bd3ca5c8f48f4262589b04f83787a173067d8d5206b424f1954ffccc0e6
|
|
| MD5 |
d8def284c266c50f9129c416deba0af1
|
|
| BLAKE2b-256 |
e7f6c6cf7946e9216fe9192a98bfe2d807032b48051acc3985202c4f5aa4149a
|