Skip to main content

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, --help on 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:

  1. If the secrets file exists AND contains marker AND wrap[0] is on PATH: hogli re-execs itself under wrap (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. A HOGLI_SECRETS_WRAPPED=1 sentinel is set in the wrap-child's env and is inherited by any subprocesses it spawns (so composite commands like dev:reset only prompt for auth once, not once per step).
  2. 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 guide
  • hogli 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.

  1. Bump version in tools/hogli/pyproject.toml, run uv lock (hogli is a uv workspace member), add a ## <version> section to tools/hogli/CHANGELOG.md, and merge to master.

  2. 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)

Source distribution for hogli 0.2.0
File Size Uploaded
hogli-0.2.0.tar.gz 55.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hogli 0.2.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page