Agent-Friendly CLI Builder
Convert Python CLI scripts from human-only output to agent-consumable NDJSON, or scaffold a complete cli_utils package ready for open-source distribution.
Two Modes
Mode A: Convert an existing script
When the user points at a script and says "make this agent-friendly" or "add --json":
- Scan the script for output points
- Generate
cli_utils.pyif the project doesn't have one - Replace all output with structured helpers
- Verify no raw output leaks in JSON mode
Mode B: Scaffold a complete package
When the user says "create a cli_utils package" or wants an open-source library:
- Scaffold a full Python package with pyproject.toml, tests, license, README
- Include all helpers:
json_log,json_error,die,log,add_json_flag,enable_json,is_json - Add pytest test suite with full coverage
- Add MIT license (or ask user preference)
Core Architecture
The fundamental pattern: every script gets a --json flag. When active, all stdout becomes newline-delimited JSON (NDJSON). Each line is a self-contained JSON object with a standard envelope.
The NDJSON Event Envelope
Every JSON line has at minimum:
json{"event": "ready", "ts": "2026-04-30T14:00:00+00:00", "pid": 1234, "port": 8765}
event— what happened (snake_case string)ts— ISO 8601 UTC timestamp- Additional fields are event-specific kwargs
Why This Design
- NDJSON over JSON arrays: processable line-by-line, one bad line doesn't break the stream, works with grep/jq, low memory for long-running processes
--jsonopt-in over default: preserves human DX, doesn't break existing scripts or habits- Global mode flag over per-call checks: set once at startup, every helper respects it automatically
die()over repeated if/else: the patternif is_json(): json_error(); sys.exit(1) else: print(); sys.exit()appears constantly —die()collapses it to one line
The cli_utils.py Reference Implementation
When generating cli_utils.py, produce exactly this (adapt only if the project has specific needs):
python"""Shared helpers for JSON CLI output.""" import json import os import sys from datetime import datetime, timezone _json_mode = False def enable_json(): global _json_mode _json_mode = True def is_json(): return _json_mode def json_log(event: str, **kwargs): """Emit one NDJSON line to stdout.""" obj = {"event": event, "ts": datetime.now(timezone.utc).isoformat(), **kwargs} print(json.dumps(obj, default=str), flush=True) def json_error(message: str, **kwargs): """Emit a structured error event.""" json_log("error", message=message, **kwargs) def die(message: str, code: int = 1, **kwargs): """Print error and exit — JSON or human depending on mode.""" if _json_mode: json_error(message, **kwargs) else: print(message, file=sys.stderr) sys.exit(code) def add_json_flag(parser): """Add --json flag to an argparse parser.""" parser.add_argument("--json", action="store_true", help="NDJSON output for agent consumption") def log(message: str, **json_kwargs): """Print human message normally, or emit JSON event if --json is active.""" if _json_mode: json_log(json_kwargs.pop("event", "info"), message=message, **json_kwargs) else: print(message) def json_ready(**kwargs): """Emit the readiness signal — only in JSON mode. Call early in daemon startup.""" if _json_mode: json_log("ready", pid=os.getpid(), **kwargs)
Converting a Script — Step by Step
Step 1: Scan for output points
Search the target script for all places that produce output or exit:
bashgrep -n 'print(\|sys\.exit\|exit(\|input(\|os\.system.*say' TARGET.py
Categorize each hit:
- Informational print → replace with
log(message, event="descriptive_name") - Error + exit → replace with
die(message) - Status line with \r → replace with
if is_json(): json_log("status", ...) else: print("\r...", end="", flush=True) - Interactive input() → guard with
if not is_json():or add--no-interactiveflag - Side effects (say, osascript, notifications) → guard with
if not is_json(): - Import-time errors (before argparse runs) → use
sys.exit("message")(writes to stderr)
Step 2: Add the import and flag
At the top of the script, after existing imports:
pythonfrom cli_utils import add_json_flag, enable_json, is_json, json_log, log, die
In the if __name__ == "__main__" block, add to argparse:
pythonadd_json_flag(parser) args = parser.parse_args() if args.json: enable_json()
Step 3: Replace each output point
Apply the categorization from Step 1. Key patterns:
Simple informational:
python# Before print(f"Connected to {device}") # After log(f"Connected to {device}", event="connected", device=device)
Error + exit:
python# Before print("Device not found") sys.exit(1) # After die("Device not found")
Daemon readiness (first output after initialization):
python# Before print(f"Server running on port {port}") # After — json_ready() only emits in JSON mode, so always call it + human fallback json_ready(port=port) log(f"Server running on port {port}", event="ready", port=port)
Status lines (\r overwrite):
python# Before print(f"\r HR {hr} RMSSD {rmssd:.1f}", end="", flush=True) # After if is_json(): json_log("status", hr=hr, rmssd=rmssd) else: print(f"\r HR {hr} RMSSD {rmssd:.1f}", end="", flush=True)
Human-only output (banners, usage examples):
pythonif not is_json(): print("Usage: send {\"type\": \"join\", \"name\": \"Alice\"}")
Step 4: Verify
- Run
python script.py --help— confirm--jsonflag appears - Run
python script.py --json— confirm first line is valid JSON - Grep for remaining raw
print(calls — ensure each is guarded or intentional
Event Name Conventions
Use snake_case, be descriptive, keep them grep-friendly:
| Category | Events |
|---|---|
| Lifecycle | ready, shutdown, connected, disconnected |
| Data | hr, status, metric, heartbeat |
| Errors | error, retry |
| Actions | recording_started, recording_stopped, preset_change |
| Progress | scanning, connecting, downloading, importing |
Scaffolding an Open-Source Package
When the user wants a distributable package, scaffold this structure:
cli-utils-agent/ ├── pyproject.toml ├── LICENSE # MIT by default, ask user ├── README.md ├── src/ │ └── cli_utils_agent/ │ ├── __init__.py # re-exports all public API │ └── core.py # the implementation ├── tests/ │ ├── __init__.py │ ├── test_json_log.py │ ├── test_die.py │ ├── test_log.py │ └── test_add_json_flag.py └── .github/ └── workflows/ └── test.yml # CI with pytest
__init__.py — re-export public API
python# src/cli_utils_agent/__init__.py from .core import ( enable_json, is_json, json_log, json_error, die, add_json_flag, log, json_ready, ) __all__ = [ "enable_json", "is_json", "json_log", "json_error", "die", "add_json_flag", "log", "json_ready", ]
README.md template
Generate a README with: project name, one-line description, install instructions (pip install cli-utils-agent), quick usage example showing add_json_flag + enable_json + log(), API reference table listing all exports with one-line descriptions, and a link to the research background.
pyproject.toml template
toml[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "cli-utils-agent" version = "0.1.0" description = "Add agent-friendly --json NDJSON output to any Python CLI" readme = "README.md" license = "MIT" requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries", ] [project.urls] Homepage = "https://github.com/USER/cli-utils-agent" [tool.hatch.build.targets.wheel] packages = ["src/cli_utils_agent"]
Test suite
Use capsys for stdout capture, pytest.raises(SystemExit) for die(). Example:
python# tests/test_json_log.py import json from cli_utils_agent import json_log, enable_json, is_json def test_json_log_writes_ndjson(capsys): json_log("ready", port=8765, pid=42) line = capsys.readouterr().out.strip() obj = json.loads(line) assert obj["event"] == "ready" assert obj["port"] == 8765 assert "ts" in obj # tests/test_die.py import json import pytest from cli_utils_agent import die, enable_json from cli_utils_agent import core as _core def test_die_human_mode(capsys): _core._json_mode = False with pytest.raises(SystemExit) as exc: die("something broke") assert exc.value.code == 1 assert "something broke" in capsys.readouterr().err def test_die_json_mode(capsys): _core._json_mode = True try: with pytest.raises(SystemExit): die("something broke", code=10) obj = json.loads(capsys.readouterr().out.strip()) assert obj["event"] == "error" assert obj["message"] == "something broke" finally: _core._json_mode = False
Cover: json_log, json_error, die, log, add_json_flag, enable_json/is_json, json_ready.
GitHub Actions CI
yaml# .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - run: pytest -v
Add dev dependencies to pyproject.toml:
toml[project.optional-dependencies] dev = ["pytest>=8.0"]
Checklist — Run Before Declaring Done
After converting a script or creating a package:
-
--helpshows--jsonflag - Running with
--jsonproduces valid NDJSON (every line is parseable JSON) - First JSON line from daemons has
"event": "ready" - Error paths emit
"event": "error"with non-zero exit code - No raw
print()can fire when--jsonis active - Import-time errors (missing deps) use
sys.exit("message")notprint() - Interactive prompts are guarded
- Side effects (voice, notifications) are guarded
- Human output is preserved when
--jsonis NOT passed - Unhandled exceptions don't leak tracebacks to stdout in JSON mode (wrap main in try/except, emit json_error)
- Tests pass (if package mode)
- Package builds cleanly:
python -m build(if package mode) - Install in clean venv and import works (if package mode)
Further Reading
Read references/best-practices.md when you need:
- Heartbeat patterns for liveness detection (section 2.4)
- Exit code conventions and string error codes (section 2.3)
- Schema introspection with
--schema(section 2.5) - CLI vs MCP decision matrix (section 2.7)
- Token efficiency tips for agent consumption (section 5)

