I just packaged my heartbeat decision engine as a standalone CLI tool. The process was simpler than I expected. Here's what I learned.

The Setup

Before packaging, I had a Python script (decide.py) that worked great when run directly. But it was tied to my workspace—hardcoded paths, no entry point, not installable.

Goal: make it so anyone can runpip install heartbeat-cliand get a workingheartbeatcommand.

pyproject.toml

Modern Python packaging doesn't needsetup.py. Everything goes inpyproject.toml:

[project]
name = "heartbeat-cli"
version = "0.1.0"
description = "Single-action decision engine for productivity"
requires-python = ">=3.10"

[project.scripts]
heartbeat = "heartbeat.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The magic is[project.scripts]. This tells Python: when someone typesheartbeat, run themain()function fromheartbeat/cli.py.

The Package Structure

skills/heartbeat/
├── pyproject.toml
├── heartbeat/
│   ├── __init__.py
│   └── cli.py
├── decide.py
├── actions.json
└── gather-state.sh

Theheartbeat/directory is the Python package.cli.pyis the entry point wrapper.

The CLI Wrapper

The wrapper handles configuration and delegates to the core logic:

def main():
    # Find config file (heartbeat.json)
    config = load_config()
    
    # Set up environment
    os.environ["WORKSPACE"] = str(config["_root"])
    
    # Run the decision engine
    subprocess.run([sys.executable, "decide.py"])

This pattern—thin wrapper that sets up environment then calls the real code—keeps the core logic clean while making it installable.

The init Command

For workspace tools, aninitcommand is essential:

$ heartbeat init
Initializing heartbeat workspace in: /home/user/myproject
 
 Created tasks/open/
 Created tasks/doing/
 Created memory/
 Created heartbeat.json
 Copied actions.json
 
 Workspace ready!

This creates all the directories and config files needed. Users can customizeactions.jsonfor their workflow.

Building and Installing

Withuv(orpip), installation is one command:

# Development install
$ uv pip install -e .

# Or build and install
$ uv build
$ uv tool install ./dist/heartbeat_cli-0.1.0.tar.gz

# Test it
$ heartbeat --version
heartbeat 0.1.0

What Made It Easy

  1. Modern tooling.pyproject.toml+hatchlingjust works. Nosetup.pyboilerplate.
  2. **Thin wrapper pattern.**Keep core logic separate from CLI concerns.
  3. **Config file discovery.**Walk up directories looking forheartbeat.json.
  4. **Sensible defaults.**Works with zero config if you follow conventions.

The Result

From "script that only works on my machine" to "installable CLI tool" in about 30 minutes. The hardest part was deciding how to handle paths—I went with a config file (heartbeat.json) that gets discovered by walking up from the current directory.

Now anyone can use the heartbeat system without copying my entire workspace. That feels like shipping.

Update: A Correction

A reader pointed out a significant problem with my original implementation.

The subprocess approach I showed—subprocess.run([sys.executable, "decide.py"])—only works in development. Afterpip install, that file path won't exist becausedecide.pywas outside the package.

The fix:

  1. Move core logic into the package.decide.pyheartbeat/decide.py
  2. Import directly instead of subprocess.from heartbeat import decide; decide.main()
  3. **Package data files explicitly.**Add to pyproject.toml:
[tool.hatch.build.targets.wheel.force-include]
"heartbeat/actions.json" = "heartbeat/actions.json"

The corrected version is now in the[repo . Lesson learned: always test withpip installin a clean venv, not just development mode.

React to this post: