Skip to main content

Zush is a CLI framework for building nested CLI applications.

Project description

zush

Zack's useful shell — a Click-based CLI that discovers and runs plugin commands from configured environments. One entry point (zush) loads plugins and exposes their commands as subcommands, with hooks and a shared context.

Requirements

  • Python 3.12+
  • Click ≥ 8.0

Installation

# From the repo (e.g. with uv)
uv sync
uv run zush --help

Or install as a package and use the zush console script.

Quick start

# List commands (built-in tree view)
zush self map

# Run a plugin command (once you have envs configured)
zush <group> <command> ...

Try it without config — use the repo playground and skip cache:

uv run zush --mock-path ./playground self map
uv run zush --mock-path ./playground demo greet

--mock-path / -m uses only that directory as the plugin env and disables cache/sentry.

Normal config-based runs use cache and sentry. If an env has not changed, zush should still rebuild the live command tree from cached package paths; an unchanged env must not collapse the CLI down to self only.

Config

Location: ~/.zush/config.toml

If the config file does not exist, zush now creates a bootstrap config on first run with include_current_env = true so plugins installed into the current interpreter are discoverable by default.

Key Description
envs List of paths to scan for plugins (folders or site-packages).
env_prefix Package name prefix(es), default ["zush_"]. Only packages whose name starts with one of these are loaded.
playground Optional path scanned first (overloaded index); first-wins merge. Good for local dev.
include_current_env Optional boolean; when true, also scan the current interpreter's site-packages (e.g. the uv env running zush).

Example:

envs = ["/path/to/my/envs", "/another/path"]
env_prefix = ["zush_", "my_"]
playground = "/path/to/zush/playground"   # optional
include_current_env = true                # also scan the env running `zush`

Config, cache, and sentry live under ~/.zush/ by default. When embedding zush, you can pass a custom storage so config/cache use a different directory; include_current_env controls whether the current interpreter's site-packages are also scanned.

Plugins

  • Discovery: From each env path, zush looks for directories whose name starts with one of the env_prefix values and that contain __zush__.py at the root.
  • Contract: In __zush__.py, export a plugin instance (e.g. an object with a .commands dict). commands is dict[str, click.Command | click.Group]; keys are dotted paths (e.g. demo.greet, tools.convert).
  • Hooks (optional): On the same instance you can define before_cmd, after_cmd, on_error, on_ctx_match (lists of patterns/callbacks). These are registered with the core and run around command execution or when the shared context is updated; they are not exposed as CLI commands.

Migration rule: When migrating an existing package into zush discovery, put __zush__.py inside the installed package directory that zush will actually scan. If the package zush is meant to discover is applewood_letty_chaos_photos, the plugin entrypoint belongs at applewood_letty_chaos_photos/__zush__.py inside that installed package.

Do not split a migration into a second sibling plugin package by default. A sibling package such as zush_applewood_letty_chaos_photos is only valid when the task explicitly requires a separate distribution and the packaging/install flow is updated to ship that package into the scanned environment.

Real-world check: Before assuming discovery is broken, confirm all of the following line up in the same environment:

  • The directory listed in envs or discovered through include_current_env.
  • The installed package directory name under that env's site-packages.
  • A matching env_prefix value.
  • A __zush__.py file inside that installed package directory.

If those line up but zush intermittently shows only self, the next thing to check is cached discovery behavior: unchanged envs must be rehydrated from cache into the live command tree, not skipped entirely.

Minimal plugin:

# my_env/zush_hello/__zush__.py
import click

class ZushPlugin:
    def __init__(self):
        self.commands = {
            "hello": click.Command("hello", callback=lambda: click.echo("Hello"))
        }

ZushPlugin = ZushPlugin()  # export instance

Helper (optional): Use zush.plugin for a chainable builder so you don’t manage dotted keys by hand:

# my_env/zush_hello/__zush__.py
import click
from zush.plugin import Plugin

p = Plugin()
p.group("hello", help="Greetings").command("say", callback=lambda: click.echo("Hi"), help="Say hi")
ZushPlugin = p

Persisted plugin config: Helper-based plugins can persist state with persistedCtx().

import click
from zush.plugin import Plugin

@click.command("save")
def save_cmd():
    with ZushPlugin.persistedCtx() as state:
        state["count"] = state.get("count", 0) + 1
    click.echo("saved")

p = Plugin()
p.group("persist", help="Persisted state demo").command("save", callback=save_cmd.callback)
ZushPlugin = p

By default this uses ~/.zush/cfg-index.json to map the plugin package name to a UUID, then stores payload files under ~/.zush/cfgs/{uuid}/....

  • with ZushPlugin.persistedCtx(): uses zush.json
  • with ZushPlugin.persistedCtx("notes.txt"): uses plain text
  • with ZushPlugin.persistedCtx("settings.toml"): uses TOML
  • with ZushPlugin.persistedCtx("settings.yaml"): uses YAML

Persistence identity is package-name based. If the same plugin package name appears in multiple scanned envs, they intentionally share the same persisted config.

See playground/zush_demo and playground/zush_hooks_demo for examples.

Reserved group: self

  • self is reserved; plugins cannot register commands under it.
  • Built-in command: zush self map — prints the command tree (like tree).
  • Built-in command: zush self config — opens the active zush config folder.

Embedding

Zush can be used as a subcommand group of another Click app, with its own config and storage:

import click
from zush import create_zush_group
from zush.config import Config
from zush.paths import DirectoryStorage, temporary_storage
from pathlib import Path

app = click.Group("myapp")

# Default: use ~/.zush for config/cache
app.add_command(create_zush_group(), "zush")

# Custom envs and storage directory
storage = DirectoryStorage(Path("/myapp/data/zush"))
config = Config(envs=[Path("/my/envs")], env_prefix=["zush_"])
app.add_command(create_zush_group(config=config, storage=storage), "zush")

# Temporary isolated storage (tests, demos, disposable sessions)
with temporary_storage() as temp_storage:
    app.add_command(create_zush_group(config=config, storage=temp_storage), "temp-zush")

Then: myapp zush self map, myapp zush <plugin commands>, etc.

Factory signature: create_zush_group(name="zush", config=None, storage=None, mock_path=None). Omitted config/storage use default (load from ~/.zush). mock_path overrides envs and disables cache for that run.

If you need an isolated config/cache/cfg-index directory, use temporary_storage() from zush.paths. It yields a DirectoryStorage backed by a temp directory and cleans it up automatically when the context exits.

Playground

The playground/ directory contains sample plugins (zush_demo, zush_hooks_demo). Use --mock-path ./playground to run against them without editing config. See playground/README.md for details.

Playground examples are for demos and local verification only. They are not a substitute for placing __zush__.py in the real installed package when the task is a real migration.

Development

uv sync --extra dev
uv run pytest

Tests live in tests/; pythonpath is set to src.

Memory bank

Project context and design live in memory-bank/. Cline (and similar tooling) reads these files at task start as the source of truth for scope, architecture, and current focus.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zush-0.1.3.tar.gz (14.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zush-0.1.3-py3-none-any.whl (19.0 kB view details)

Uploaded Python 3

File details

Details for the file zush-0.1.3.tar.gz.

File metadata

  • Download URL: zush-0.1.3.tar.gz
  • Upload date:
  • Size: 14.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for zush-0.1.3.tar.gz
Algorithm Hash digest
SHA256 6bdff62da5010fcfad655a00f2a297405f1a65f8f2acee24f57ef49be57295b6
MD5 9549cc94ea71ea1f11bc319f0a8ac45c
BLAKE2b-256 4e4c6b1b965af7ba1cc1b15ad8619280cfaf9c4a189a33edd52d3e4686cefecb

See more details on using hashes here.

Provenance

The following attestation bundles were made for zush-0.1.3.tar.gz:

Publisher: publish.yml on ZackaryW/zush

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zush-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: zush-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 19.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for zush-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 7a161fded29fe2330ab49d298370aa932581bcd4bee8615b616ffd96fe0cac50
MD5 398b541f29644910a96bd77fd376e005
BLAKE2b-256 79634687980e427b1ccb1944185f39ee6f82e8bfe2ee440d2da39e92cafda8d8

See more details on using hashes here.

Provenance

The following attestation bundles were made for zush-0.1.3-py3-none-any.whl:

Publisher: publish.yml on ZackaryW/zush

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page