
Agent Cli
- 52 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Add agent-friendly --json NDJSON output to Python CLI scripts, or scaffold a full cli_utils package for machine-readable CLI I/O.
About
Converts Python CLI scripts to emit agent-consumable NDJSON via a --json flag, or scaffolds a complete cli_utils package with helpers, tests, and license. A developer uses it to make scripts machine-readable for AI agents or to build an open-source CLI-for-agents library.
- Standard NDJSON event envelope with event, ts, and event-specific fields
- Two modes: convert an existing script, or scaffold a full pyproject package with tests
Agent Cli by the numbers
- 52 all-time installs (skills.sh)
- Ranked #305 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill agent-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Add agent-friendly --json NDJSON output to Python CLI scripts, or scaffold a full cli_utils package for machine-readable CLI I/O.
Files
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":
1. Scan the script for output points 2. Generate cli_utils.py if the project doesn't have one 3. Replace all output with structured helpers 4. 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:
1. Scaffold a full Python package with pyproject.toml, tests, license, README 2. Include all helpers: json_log, json_error, die, log, add_json_flag, enable_json, is_json 3. Add pytest test suite with full coverage 4. 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:
{"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
- `--json` opt-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 pattern
if 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):
"""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:
grep -n 'print(\|sys\.exit\|exit(\|input(\|os\.system.*say' TARGET.pyCategorize 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:
from cli_utils import add_json_flag, enable_json, is_json, json_log, log, dieIn the if __name__ == "__main__" block, add to argparse:
add_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:
# Before
print(f"Connected to {device}")
# After
log(f"Connected to {device}", event="connected", device=device)Error + exit:
# Before
print("Device not found")
sys.exit(1)
# After
die("Device not found")Daemon readiness (first output after initialization):
# 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):
# 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):
if not is_json():
print("Usage: send {\"type\": \"join\", \"name\": \"Alice\"}")Step 4: Verify
1. Run python script.py --help — confirm --json flag appears 2. Run python script.py --json — confirm first line is valid JSON 3. 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
# 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
[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:
# 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 = FalseCover: json_log, json_error, die, log, add_json_flag, enable_json/is_json, json_ready.
GitHub Actions CI
# .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 -vAdd dev dependencies to pyproject.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)
{
"skill_name": "agent-cli",
"evals": [
{
"id": 1,
"prompt": "I have this Python CLI script at /tmp/test-agent-cli/weather.py that fetches weather data and prints it. Make it agent-friendly with --json support. Here's the script:\n\nimport argparse\nimport sys\n\ndef fetch_weather(city):\n # simulated\n return {\"temp\": 22, \"humidity\": 65, \"condition\": \"cloudy\"}\n\nif __name__ == \"__main__\":\n parser = argparse.ArgumentParser(description=\"Weather CLI\")\n parser.add_argument(\"city\", help=\"City name\")\n args = parser.parse_args()\n \n data = fetch_weather(args.city)\n if not data:\n print(\"Error: could not fetch weather\")\n sys.exit(1)\n \n print(f\"Weather for {args.city}:\")\n print(f\" Temperature: {data['temp']}°C\")\n print(f\" Humidity: {data['humidity']}%\")\n print(f\" Condition: {data['condition']}\")",
"expected_output": "Modified weather.py with --json flag, cli_utils.py generated, all prints replaced with log() calls, die() for error path",
"files": []
},
{
"id": 2,
"prompt": "Create a complete open-source cli-utils-agent Python package at /tmp/test-agent-cli/cli-utils-agent/ with pyproject.toml, MIT license, README, tests, and GitHub Actions CI. It should provide helpers for adding --json NDJSON output to any Python CLI script.",
"expected_output": "Full package scaffolded with src/ layout, pyproject.toml, LICENSE, README.md, test suite, CI workflow",
"files": []
},
{
"id": 3,
"prompt": "I have a WebSocket relay daemon at /tmp/test-agent-cli/relay.py. Add --json support. The daemon needs a readiness signal. Here's the script:\n\nimport asyncio\nimport json\n\nasync def handler(ws):\n print(f'Client connected from {ws.remote_address}')\n async for msg in ws:\n data = json.loads(msg)\n print(f'Received: {data[\"type\"]}')\n await ws.send(json.dumps({\"ok\": True}))\n print(f'Client disconnected')\n\nasync def main():\n import websockets\n server = await websockets.serve(handler, '0.0.0.0', 9000)\n print(f'Relay running on port 9000')\n print('Waiting for connections...')\n await server.wait_closed()\n\nif __name__ == '__main__':\n asyncio.run(main())",
"expected_output": "Modified relay.py with --json flag, readiness signal as first JSON line, all prints replaced, cli_utils.py generated",
"files": []
}
]
}
CLI Design for AI Agents
Practical patterns for building CLIs that are consumed by AI agents, not just humans. Compiled for the [[polar-h10-ribbon]] HRV biofeedback project.
Core principle: Human DX optimizes for discoverability and forgiveness. Agent DX optimizes for predictability and defense-in-depth. Design for both simultaneously.
---
1. Key Principles
- stdout is your API. Structured (machine-readable) output goes to stdout. Human messages, progress, and diagnostics go to stderr.
- TTY detection. When stdout is not a TTY, default to JSON. When it is a TTY, show human-friendly output. The
--jsonflag forces JSON regardless. - Output formats are API contracts. Treat them with the same discipline as REST APIs: semver, CI schema checks, no silent breakage.
- Meaningful exit codes. Agents branch on failure modes. 0 = success, 1 = generic error, 2 = bad usage, 3-125 = application-specific.
- Idempotent operations. Agents retry. Design commands so re-running them produces the same result.
- Non-interactive by default. Never block on stdin. Provide
--no-prompt,--no-interactive, or detect non-TTY and skip prompts automatically.
---
2. Pattern Catalog
2.1 NDJSON Event Envelope
Every line is a self-contained JSON object with a standard envelope:
{"event": "ready", "ts": "2026-04-30T14:00:00Z", "pid": 1234, "port": 8765}
{"event": "hr", "ts": "2026-04-30T14:00:01Z", "bpm": 72, "rr_ms": [831, 845]}
{"event": "metric", "ts": "2026-04-30T14:00:05Z", "rmssd": 42.3, "sdnn": 55.1}
{"event": "error", "ts": "2026-04-30T14:00:10Z", "message": "BLE disconnected", "code": "ble_disconnect", "retry": true}Required fields in every line:
event-- the event type (string, snake_case)ts-- ISO 8601 UTC timestamp
Why NDJSON over JSON arrays:
- Can be processed incrementally (one line at a time)
- One corrupted line does not break the stream
- Easily appendable (log files, streaming)
- Works with line-oriented tools (grep, jq, wc)
- Low memory footprint for long-running processes
2.2 Daemon Readiness Signal
The first JSON line a daemon emits is its readiness announcement. The orchestrating agent reads this line to confirm the process started successfully.
{"event": "ready", "ts": "...", "pid": 1234, "port": 8765, "version": "0.3.0"}Pattern: The agent starts the subprocess, reads the first line of stdout, parses it, and confirms event == "ready". If it does not arrive within a timeout (e.g. 10s), the agent treats the process as failed.
Alternatives considered:
- Pidfiles: race-prone, stale files cause confusion
- Health endpoints: require HTTP, adds complexity
- systemd-notify: Linux-only, requires sd_notify()
- First-line JSON: cross-platform, zero dependencies, works with any language
2.3 Structured Error Reporting
Errors are NDJSON lines with event: "error", not unstructured stderr text.
{"event": "error", "ts": "...", "message": "Device not found", "code": "device_not_found", "detail": "No Polar H10 in range after 30s scan", "retry": true}Fields:
message-- human-readable summarycode-- machine-parseable error type (snake_case string, not a number)detail-- optional longer explanationretry-- hint to the agent whether retrying makes senseexit_code-- if the process is about to exit, include it
Exit code conventions for this project:
| Code | Meaning |
|---|---|
| 0 | Success / clean shutdown |
| 1 | Generic runtime error |
| 2 | Bad arguments / usage error |
| 10 | BLE device not found |
| 11 | BLE connection lost |
| 12 | BLE permission denied |
| 20 | Database error |
| 21 | Database locked |
| 30 | WebSocket error |
| 40 | Sensor data quality issue |
2.4 Streaming Status for Long-Running Processes
Daemons like bridge.py and hrv_lights.py run indefinitely. They stream status as periodic NDJSON heartbeats:
{"event": "heartbeat", "ts": "...", "uptime_s": 120, "samples": 1500, "bpm": 68, "connected": true}Rules:
- Emit a heartbeat every 10-30 seconds even when nothing changes
- Include a monotonically increasing counter or uptime for liveness detection
- The agent can kill the process if heartbeats stop for > 2x the interval
- State transitions (connected/disconnected) get their own events immediately, not batched into heartbeats
2.5 Schema Introspection
Agents should not need to parse --help text. Provide:
python bridge.py --schemaReturns:
{
"name": "bridge",
"version": "0.3.0",
"description": "Polar H10 BLE to WebSocket bridge",
"args": {
"--json": {"type": "bool", "default": false, "help": "NDJSON output"},
"--port": {"type": "int", "default": 8765, "help": "WebSocket port"},
"--device": {"type": "str", "help": "BLE device serial"}
},
"events": ["ready", "hr", "ecg", "acc", "error", "heartbeat"],
"exit_codes": {"0": "success", "10": "device_not_found", "11": "connection_lost"}
}This lets agents discover capabilities without wasting tokens on help text parsing.
2.6 CLI Orchestrator Pattern
For managing multiple services (bridge + lights + metrics + relay), use a parent process that:
1. Starts each child with --json 2. Reads the first ready line from each 3. Multiplexes all NDJSON streams into a single output, adding a source field 4. Forwards signals (SIGTERM) to all children 5. Restarts children on unexpected exit
{"event": "ready", "ts": "...", "source": "bridge", "pid": 1234, "port": 8765}
{"event": "ready", "ts": "...", "source": "lights", "pid": 1235}
{"event": "hr", "ts": "...", "source": "bridge", "bpm": 72}
{"event": "color", "ts": "...", "source": "lights", "hue": 120, "brightness": 80}The orchestrator itself emits:
{"event": "orchestra_ready", "ts": "...", "services": ["bridge", "lights", "metrics", "relay"]}2.7 CLI vs MCP: When to Use Each
Per RudderStack's pattern:
| Operation | Interface | Reason |
|---|---|---|
| Start/stop recording | CLI | State-changing, needs explicit control |
| Query HRV history | MCP or CLI query | Read-only, safe to explore |
| Change light preset | CLI | State-changing mutation |
| Get current session status | MCP or CLI status | Read-only |
| Configure protocol | CLI + config file | Agent generates config, human reviews |
Rule of thumb: Write operations go through CLI with explicit flags. Read operations can go through either CLI or MCP.
---
3. Implementation Checklist for polar-h10-ribbon
Current state: cli_utils.py already implements the basic envelope (json_log, json_error, add_json_flag). The existing envelope uses event + ts fields -- this is solid.
What to add:
- [ ] Readiness signal: Every daemon (
bridge.py,relay.py,hrv_lights.py) should emit{"event": "ready", ...}as its first JSON line after initialization - [ ] Heartbeats: Bridge should emit periodic heartbeats with connection status, sample count, current BPM
- [ ] Exit codes: Define project-wide exit code constants in
cli_utils.py - [ ] Error codes: Add string error codes to
json_error()-- e.g.,json_error("BLE scan failed", code="ble_scan_timeout") - [ ] Schema command: Add
--schemaflag to each script that dumps args and event types as JSON - [ ] Orchestrator: Build a
run_all.pythat starts bridge + lights + metrics, reads readiness, and multiplexes output - [ ] stderr for human output: When
--jsonis active, redirectlog()calls to stderr so stdout stays clean NDJSON - [ ] Non-interactive guards: Ensure no script ever blocks on stdin input
Current cli_utils.py improvements:
# Add to cli_utils.py:
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_USAGE = 2
EXIT_BLE_NOT_FOUND = 10
EXIT_BLE_DISCONNECTED = 11
EXIT_BLE_PERMISSION = 12
EXIT_DB_ERROR = 20
EXIT_DB_LOCKED = 21
EXIT_WS_ERROR = 30
EXIT_DATA_QUALITY = 40
def json_ready(**kwargs):
"""Emit the readiness signal. Must be the first JSON line."""
json_log("ready", pid=os.getpid(), **kwargs)
def json_heartbeat(**kwargs):
"""Emit a periodic liveness signal."""
json_log("heartbeat", **kwargs)---
4. MCP Stdio Transport Reference
MCP uses JSON-RPC 2.0 over stdio. The pattern is similar to our NDJSON approach but with a formal request/response protocol:
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_hrv", "arguments": {"window": 60}}}
{"jsonrpc": "2.0", "id": 1, "result": {"rmssd": 42.3, "sdnn": 55.1, "hr": 68}}Key differences from our NDJSON pattern:
- MCP is request/response; our pattern is event-streaming
- MCP has formal method registration; we use ad-hoc event types
- MCP requires a JSON-RPC envelope with
idandjsonrpcfields
When to consider MCP: If we want Claude Code or other agents to interact with live HRV data through tool calls rather than stream parsing, we could wrap the bridge as an MCP server. For now, NDJSON streaming is simpler and sufficient.
---
5. Token Efficiency
CLIs are 10-32x cheaper on tokens than MCP for most tasks. Agents chain commands in quick sequences where one output pipes to the next. Key optimizations:
- Field masks:
--fields bpm,rmssd,tsto limit output to what the agent needs - Compact output: No pretty-printing in JSON mode (no indentation)
- Pagination as NDJSON: Stream one object per page instead of buffering arrays
- Short error codes:
"ble_scan_timeout"not"Bluetooth Low Energy scan timed out after 30 seconds while searching for compatible heart rate monitors"
---
References
- You Need to Rewrite Your CLI for AI Agents -- Most comprehensive guide; covers schema introspection, input hardening, skill files
- Keep the Terminal Relevant: Patterns for AI Agent Driven CLIs -- InfoQ article on exit codes, structured output as API contracts, MCP integration
- AI agents need two interfaces: CLI and MCP -- RudderStack on write-via-CLI, read-via-MCP separation
- Rewrite Your CLI for Agents (Or Get Replaced) -- DEV Community overview of agent-friendly patterns
- Command Line Interface Guidelines -- The canonical CLI design guide; stdout/stderr separation,
--jsonflag, output as API - NDJSON Specification -- Newline Delimited JSON format reference
- JSON Lines -- Alternative name for the same spec
- MCP Transports -- stdio transport for JSON-RPC 2.0
- Structured CLI Output as Pipeline Glue -- Steve Kinney on CLI output for agent pipelines
- Designing CLIs for AI Agents -- 2026 patterns overview
- 10 Must-have CLIs for your AI Agents -- Practical agent CLI tools