hogli
A developer CLI framework for defining commands in YAML. Think of it as "GitHub Actions for your local dev environment" - declarative, composable, and easy to maintain.
Why hogli?
- Declarative: Define commands in YAML instead of scattered shell scripts
- Composable: Chain commands together with
steps - Discoverable: Auto-generated help with categories,
--helpon every command - Extensible: Add complex commands in Python when YAML isn't enough
Installation
pip install hogli
Quick Start
Create hogli.yaml in your repo root:
config:
scripts_dir: bin # Where bin_script looks for scripts (default: bin/)
metadata:
categories:
- key: dev
title: Development
- key: test
title: Testing
dev:
dev:start:
cmd: docker compose up -d && npm run dev
description: Start development environment
dev:reset:
steps:
- dev:stop
- dev:clean
- dev:start
description: Full environment reset
test:
test:unit:
cmd: pytest tests/unit
description: Run unit tests
Run commands:
hogli dev:start
hogli --help # Shows all commands grouped by category
hogli dev:start -h # Help for specific command
Command Types
Shell commands (cmd)
Run shell commands directly. Supports shell operators (&&, ||, |):
build:
cmd: npm run build && npm run test
description: Build and test
Script delegation (bin_script)
Delegate to scripts in your scripts_dir:
deploy:
bin_script: deploy.sh
description: Deploy to production
Composite commands (steps)
Chain multiple hogli commands:
release:
steps:
- test:all
- build
- deploy
description: Full release pipeline
Steps can also include inline commands:
setup:
steps:
- name: Install deps
cmd: npm install
- name: Build
cmd: npm run build
- test:unit
Command Options
my:command:
cmd: echo "hello"
description: Short description for --help
destructive: true # Prompts for confirmation before running
hidden: true # Hides from --help (still runnable)
needs_secrets: true # Triggers the configured `env.secrets.wrap` (see below)
untracked: true # Skips command_started/command_completed telemetry (use for exec-style commands that replace the process, so a completed event could never fire)
Python Commands
For complex logic, define plain Click commands and reference them from hogli.yaml with click: module.path:attribute.
Click command modules are lazy-loaded. Top-level hogli --help uses the manifest description without importing Python command modules; hogli <command> --help and command execution import the target on demand.
The Click command name must match the manifest key — drift surfaces as a ClickException on resolution. The framework follows Click's recommendation that lazy loading be paired with a test that runs --help on each subcommand; PostHog's test suite parametrizes over every click: entry in hogli.yaml to do exactly that.
Mark commands hidden via hidden: true in hogli.yaml. Don't use @click.command(hidden=True) — the manifest is the single source of truth.
Minimal: one importable package
your-repo/
├── hogli.yaml
└── tools/
└── hogli_commands/
├── __init__.py
└── db.py
# hogli.yaml
config:
commands_dir: tools/hogli_commands
db:
db:migrate:
click: hogli_commands.db:db_migrate
description: Run database migrations
# tools/hogli_commands/db.py
import click
@click.command(name="db:migrate")
@click.option("--dry-run", is_flag=True, help="Show SQL without executing")
def db_migrate(dry_run: bool) -> None:
"""Run database migrations."""
if dry_run:
click.echo("Would run migrations...")
else:
# Your migration logic here
pass
commands_dir is optional and explicit: hogli only uses it when configured. It must be a relative path to an existing directory. hogli puts that directory's parent on sys.path, so the directory should be an importable package or module tree. It must not be named hogli, since that shadows the installed framework.
Full: project package with submodules
For a larger command surface, keep the distribution/project directory separate from the import package:
your-repo/
├── hogli.yaml
└── tools/
└── hogli-commands/
├── pyproject.toml
└── hogli_commands/ # underscored: this is the import name
├── __init__.py
├── build.py
├── db.py
└── deploy.py
# hogli.yaml
config:
commands_dir: tools/hogli-commands/hogli_commands
build:
build:
click: hogli_commands.build:build
description: Run build pipelines
The dashed outer dir + underscored inner package follows PEP 8 (dashed project name, underscored import name). This is the layout PostHog itself uses.
Extension Hooks
Extensions can inject behavior at three framework call sites without forking. Register hooks from modules listed in config.boot_modules; those modules are imported once at startup, before command dispatch. Keep boot modules cheap to import and move heavy work inside hook functions. Exceptions raised by hooks are swallowed, so one extension can't break another.
config:
commands_dir: tools/hogli_commands
boot_modules:
- hogli_commands.boot
Prechecks
Run validation before a command executes, keyed by type: in a prechecks: entry in hogli.yaml. Return False to abort, True/None to continue.
from hogli.hooks import register_precheck
def check_migrations(check: dict, yes: bool) -> bool | None:
# inspect check config, prompt user, decide
return None
register_precheck("migrations", check_migrations)
dev:start:
cmd: docker compose up -d
prechecks:
- type: migrations
Telemetry properties
Inject extra key/value pairs into the command_started / command_completed telemetry events. Receives the invoked command name.
from hogli.hooks import register_telemetry_properties
def env_props(command: str | None) -> dict[str, object]:
return {"in_my_env": True}
register_telemetry_properties(env_props)
A command can also stash properties for its own command_completed event, for context only that command knows:
from hogli import telemetry
telemetry.add_command_properties(failure_cause="unreachable")
Post-command hooks
Run after every command completes, regardless of success. Good for contextual hints, cleanup, or notifications.
from hogli.hooks import register_post_command_hook
def maybe_show_hint(command: str | None, exit_code: int) -> None:
if exit_code == 0:
...
register_post_command_hook(maybe_show_hint)
Environment files
hogli can load dotenv files into every command's environment, the same way
just,
mise, and
task do. Declare them in hogli.yaml:
config:
env:
files:
- .env.development # loaded in order; earlier wins for duplicate keys
- .env.services # shell env always wins (only_if_unset)
Missing files are silently skipped — same convention as the other tools — so
.env.local is fine to leave optional.
Secret references (config.env.secrets)
Loading raw secrets from disk is a separate problem from loading dotenv files, and tools like 1Password CLI, Doppler, Infisical, Vault, and dotenvx all expose the same pattern: wrap the command they should run, fetch the secrets at runtime, inject them into the subprocess env. hogli supports this generically — no provider-specific knowledge in core:
config:
env:
secrets:
file: .env.local # the file that contains secret references
marker: 'op://' # substring that triggers the wrap; required, non-empty
wrap: [op, run, --env-file, '{file}', --]
Opt commands into the wrap with needs_secrets: true on the manifest entry:
start:
bin_script: start
description: Launch the dev stack
needs_secrets: true # this command needs runtime secrets resolved
Commands without the flag never trigger the wrap — important because some
wrap binaries (1Password's op on macOS, for example) prompt for biometric
auth on every invocation, and you don't want lint / format / typecheck /
pre-commit hooks pulling on that. The built-in hogli run <cmd> always
opts in, since its whole job is to forward the resolved env.
What happens at startup, for a command that opted in:
- If the secrets file exists AND contains
markerANDwrap[0]is onPATH: hogli re-execs itself underwrap(with{file}substituted to the absolute path of the secrets file). The wrap binary resolves secrets and re-runs hogli with them in the env. AHOGLI_SECRETS_WRAPPED=1sentinel is set in the wrap-child's env and is inherited by any subprocesses it spawns (so composite commands likedev:resetonly prompt for auth once, not once per step). - If the wrap binary is missing or the marker isn't present, hogli loads the
file directly with marker-matching lines skipped (so unresolved
op://...strings don't leak as garbage env values that produce confusing 401s downstream — only literal values get loaded).
For commands without needs_secrets: true, hogli skips the wrap entirely
and only loads literal values from the secrets file (marker-matching lines
are skipped). So .env.local stays useful for non-secret overrides
(DEBUG=1 etc.) even when the wrap doesn't fire.
Precedence in either path (highest wins): shell env > secrets file > env
files. So a literal override in .env.local always beats .env.development,
matching what op run does when it's available.
marker is required (not optional) — an "always wrap" mode would force a
process exec on every hogli invocation, which we never want.
The same shape works for Doppler (wrap: [doppler, run, --]), Vault
(wrap: [vault, exec, --]), Infisical (wrap: [infisical, run, --]),
dotenvx (wrap: [dotenvx, run, --]), or anything else following the
wrap-and-exec convention. hogli itself stays ignorant of which tool you use.
Why this split (and not built-in 1Password/Doppler/Vault knowledge)? Because mise's maintainer explicitly excluded secret resolution from mise core for good reasons: secret APIs are slow, caching them is a security risk, and CLI frameworks reload env too often for that to play nicely. Better to delegate to a purpose-built tool.
Configuration Reference
config:
commands_dir: path/to/commands # Optional local Python command package
boot_modules:
- package.boot # Optional eager hook registration modules
scripts_dir: scripts # For bin_script resolution (default: bin/)
env:
files: [.env.development, .env.services] # Optional dotenv files (in order)
secrets: # Optional secret-wrapper hook
file: .env.local
marker: 'op://'
wrap: [op, run, --env-file, '{file}', --]
metadata:
categories:
- key: dev
title: Development Commands
- key: test
title: Test Commands
Built-in Commands
hogli quickstart- Getting started guidehogli meta:check- Validate manifest against bin scripts (for CI)hogli meta:concepts- Show infrastructure concepts (if defined)
Requirements
- Python 3.10+
- click
- pyyaml
Releasing
Releases are published to PyPI via .github/workflows/publish-hogli.yml,
triggered by pushing a hogli-v* tag from master.
-
Bump
versionintools/hogli/pyproject.toml, runuv lock(hogli is a uv workspace member), add a## <version>section totools/hogli/CHANGELOG.md, and merge tomaster. -
From
master, tag and push:git tag hogli-v0.1.1 git push origin hogli-v0.1.1
The workflow verifies the tag matches the pyproject.toml version, extracts
that version's section from CHANGELOG.md, builds the sdist and wheel with
uv build, smoke-tests the wheel in a fresh venv, publishes via PyPI trusted
publishing (OIDC) with no API tokens, and creates a GitHub Release whose body
is the changelog section.
A tag with no matching changelog section fails the run before anything is published.
To re-trigger after a failed publish, dispatch the workflow against the existing tag — no need to retag:
gh workflow run publish-hogli.yml --ref hogli-v0.1.1
The publish job is guarded by if: startsWith(github.ref, 'refs/tags/hogli-v'),
so dispatches from a branch are no-ops.
License
MIT
Release files for hogli 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hogli-0.2.0.tar.gz | 55.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hogli-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 91.5 kB
Release files / hogli-0.2.0.tar.gz
| Download URL | hogli-0.2.0.tar.gz |
|---|---|
| Size | 55.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
89d4151709308a56aad429a8cd7642304f4eb43b30bb40bfe9be0d4619256e53
|
|
BLAKE2b-256 checksum How to use checksums |
161696137deedecb1979fc345374eb408818d116c409833d503e08f8d61ddf70
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / hogli-0.2.0-py3-none-any.whl
| Download URL | hogli-0.2.0-py3-none-any.whl |
|---|---|
| Size | 35.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b3cbd91e71ecebf026773f51a1f97975c2f04ab9ff991524c64304732b510d8e
|
|
BLAKE2b-256 checksum How to use checksums |
751bf5cdd2e421831e591398e1796e76a87be15dd687b0df484da6a40ae0853b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|