Skip to content

murli-cli/murli-py

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

murli 🎶

PyPI License: MIT

Python middleware for CLI tools that makes them speak natively to AI agents — with adapters for click, typer, and argparse.

murli is named after Krishna's sacred flute. The murli's music enchants every listener — each feeling it was meant for them alone. This library takes the same approach: your commands don't change, but a human at a terminal gets clear readable output, and an agent reading from a pipe gets structured JSON. Each audience gets the experience shaped for them.

Full documentation: murli.allankent.com


Philosophy

Five principles guide everything murli does:

One tool, two audiences. Humans and agents call the same commands. murli routes output automatically — no if agent { ... } branches in your code.

Discoverability is a first-class feature. Agents shouldn't need documentation to use your tool. The tool describes itself.

Errors are instructions, not messages. A structured error tells an agent what went wrong, whether to retry, and what to do instead.

Dangerous operations require explicit intent. Mutations are rejected in non-interactive mode until the agent — or human — confirms they know what they're doing.

Context windows are finite. Log deduplication, clean stderr routing, and streaming results keep agent context consumption predictable.


Installation

pip install murli                  # core only (argparse adapter included)
pip install "murli[click]"         # + click adapter
pip install "murli[typer]"         # + typer adapter
pip install "murli[all]"           # click + typer

Quick Start

click

import click
import murli

@click.group()
def cli(): pass

murli.enable(cli)  # one line — adds --agent, --schema, --force, --dry-run, --output, --profile
                   # mounts describe, doctor, profile subcommands

@cli.command()
@murli.pass_writer
def deploy(writer):
    writer.write_success("Deployed", {"status": "ok"})

if __name__ == "__main__":
    cli()

typer

import typer
import murli

app = typer.Typer()
murli.enable(app)

@app.command()
def deploy(ctx: typer.Context):
    writer = murli.get_writer(ctx)
    writer.write_success("Deployed", {"status": "ok"})

if __name__ == "__main__":
    app()

argparse

import argparse
import murli

parser = argparse.ArgumentParser(description="My tool")
murli.enable(parser)
parser.add_argument("--env", required=True)

args, writer = murli.parse(parser)   # drop-in for parse_args()
writer.write_success(f"Deployed to {args.env}", {"env": args.env})

Capabilities

Dual-Audience Output

The same command produces plain text for humans and structured JSON for agents. murli detects whether stdout is a TTY automatically.

$ ./mytool deploy
Deployed

$ ./mytool deploy | cat
{
  "status": "ok",
  "schema_version": "1.0",
  "message": "Deployed",
  "result": {}
}

Use --agent to force JSON mode without piping:

$ ./mytool deploy --agent

Use --output to control serialisation explicitly:

$ ./mytool deploy --output ndjson   # minified single-line JSON
$ ./mytool deploy --output text     # human text even when piped

Write your output once — murli routes it.

@cli.command()
@murli.pass_writer
def query(writer, text):
    results = search(text)
    writer.write_success(
        f"Found {len(results)} results",   # human text
        results,                            # agent payload
    )

Command Introspection

describe is auto-mounted — one call, full tree.

$ ./mytool describe
{
  "name": "mytool",
  "schema_version": "1.0",
  "capabilities": ["agent", "schema", "dry-run", "force", "profiles"],
  "commands": [...]
}

Generate an AGENTS.md stub for your repository:

$ ./mytool describe --agents-md > AGENTS.md

--schema is auto-registered on every command.

$ ./mytool deploy --schema
{
  "name": "deploy",
  "summary": "Deploy to production",
  "idempotent": false,
  "flags": [...]
}

doctor checks naming conventions.

$ ./mytool doctor
All naming conventions satisfied.

Rich Agent Metadata

Annotate commands to give agents richer signal:

murli.annotate(deploy_cmd, murli.Metadata(
    agent_description="Deploys the application to the target environment.",
    when_to_use="Use when the build has passed and artifacts are ready.",
    idempotent=False,
    mutating=True,
    dry_runnable=True,
    returns=murli.ReturnSchema(
        description="Deployment result",
        type="object",
        properties={"env": "string", "version": "string"},
    ),
    examples=[
        murli.Example(command="mytool deploy --env prod", description="Deploy to production"),
    ],
    flag_annotations={
        "env": murli.FlagAnnotation(
            enum=["dev", "staging", "prod"],
            env="MYTOOL_ENV",
        ),
        "token": murli.FlagAnnotation(sensitive=True, env="MYTOOL_TOKEN"),
    },
))

FlagAnnotation fields:

Field Type Purpose
env str Environment variable that sets this flag
sensitive bool Flag carries secrets; agents must not log its value
persistent bool Flag applies to all subcommands
enum list[str] Exhaustive list of valid values
pattern str Regex the value must match
mutually_exclusive_with list[str] Other flags that cannot be set simultaneously
profileable bool Flag can be saved in a profile

Structured Errors

Every error carries structure: what failed, whether to retry, and what to do next.

# Convenience constructors
raise murli.AgentError.user_error("query string cannot be empty", "Provide a search keyword.")
raise murli.AgentError.tool_error("database connection failed: timeout after 30s")
raise murli.AgentError.not_found("index not found at ~/.mytool/index", "Run `mytool index build`.")
raise murli.AgentError.rate_limited("API rate limit hit", retry_after_ms=5000)

# Or build the full error for precise control
raise murli.AgentError(
    code=murli.EXIT_NOT_FOUND,
    error_type="index_missing",
    message="Semantic index not found at ~/.mytool/index",
    suggestion="Run `mytool index build` to create the index first.",
    recoverable=False,
    doc_url="https://example.com/docs/indexing",
)

In agent mode, murli writes the error envelope to stderr and exits:

{
  "status": "error",
  "code": 5,
  "error": "index_missing",
  "message": "Semantic index not found at ~/.mytool/index",
  "suggestion": "Run `mytool index build` to create the index first.",
  "recoverable": false,
  "schema_version": "1.0"
}

Exit code taxonomy (identical across all murli implementations):

Code Constant Meaning
0 EXIT_OK Success
1 EXIT_USER_ERROR Bad input or configuration — fix and retry
2 EXIT_TOOL_ERROR Environment or internal failure
3 EXIT_PARTIAL Some operations succeeded, some failed
4 EXIT_TIMEOUT Operation timed out — retry after a delay
5 EXIT_NOT_FOUND Requested resource does not exist
6 EXIT_PERMISSION Insufficient permissions
7 EXIT_CONFLICT State conflict (resource already exists, etc.)
8 EXIT_RATE_LIMITED Rate limit hit — wait retry_after_ms
9 EXIT_CANCELLED Operation cancelled

Safety Rails

Mark a command mutating and the guard is automatic. In non-interactive mode (no TTY, no --force), murli rejects it before your handler runs:

@app.command()
@murli.pass_writer
def delete(writer, name: str):
    if writer.is_dry_run():
        writer.write_plan(f"Would delete {name}", {"would_delete": name})
        return
    # real deletion
    writer.write_success(f"Deleted {name}", {"deleted": name})

Annotate the command:

murli.annotate(delete_cmd, murli.Metadata(mutating=True, dry_runnable=True, destructive=True))

When called non-interactively without --force:

{
  "status": "error",
  "code": 1,
  "error": "confirmation_required",
  "message": "This command mutates state and requires explicit confirmation.",
  "suggestion": "Pass --force or --yes to proceed without a TTY.",
  "recoverable": true
}

The safety block appears automatically in --schema output:

{
  "name": "delete",
  "safety": {
    "read_only": false,
    "idempotent": false,
    "destructive": true,
    "dry_run_supported": true
  }
}

Saved Profiles

Named sets of flag values that apply automatically on every invocation.

# Save current flags as a profile
$ mytool profile set production env=prod token=abc123

# Use it by default
$ mytool profile set-default production

# Override for a single invocation
$ mytool --profile staging deploy

profile subgroup is auto-mounted by murli.enable(). Available subcommands: list, set, delete, set-default, show.

Profiles are stored in platformdirs.user_config_dir(tool_name) / "profiles.json".


Streaming & Progress

Stream incremental results with write_event:

for result in process_items(items):
    writer.write_event({"event": "chunk", "data": result})  # NDJSON line per call; no-op in TTY
writer.write_success("Processing complete", {"total": len(items)})

Report progress with write_progress:

writer.write_progress(murli.ProgressEvent(
    stage="indexing",
    current=500,
    total=2000,
    percent=25.0,
    eta_ms=6000,
    message="Indexing files",
))

Agent mode: {"event":"progress",...} NDJSON on stdout. TTY mode: human-readable on stderr.

Log with deduplication:

writer.log("Scanning /docs")     # stderr
writer.log("Scanning /docs")     # deduplicated — collapses in agent mode
writer.log("Scanning /docs")     # deduplicated

Agent stderr output:

{"ts":"2026-05-31T12:00:00","level":"info","msg":"Scanning /docs","repeated":2}

ANSI escape codes are stripped automatically in agent mode.


API Reference

murli.enable(app)

Enable murli on a click.Group, typer.Typer, or argparse.ArgumentParser. Injects --agent, --schema, --force/--yes, --dry-run, --output, --profile flags. Mounts describe, doctor, profile subcommands (on Groups).

murli.annotate(app, meta: Metadata)

Attach rich metadata to a command. All fields are optional.

murli.get_writer(ctx) -> Writer

Get the Writer from a click or typer context.

murli.parse(parser, args=None) -> (Namespace, Writer)

argparse drop-in for parse_args(). Returns both the namespace and a configured Writer.

@murli.pass_writer

click decorator — injects writer: Writer as the first argument to the decorated function.

Writer

Method TTY Agent
write_success(human_text, json_payload) stdout plain text {"status":"ok",...} to stdout
write_plan(human_text, plan) stdout plain text {"status":"plan",...} to stdout
write_error(err: AgentError) stderr plain text + exit {"status":"error",...} to stderr + exit
write_event(v) no-op NDJSON line to stdout
write_progress(evt) stderr plain text {"event":"progress",...} to stdout
log(msg) stderr stderr
is_tty() -> bool
is_forced() -> bool
is_dry_run() -> bool

murli.Metadata

@dataclass
class Metadata:
    agent_description: str = ""
    when_to_use: str = ""
    idempotent: bool = False
    mutating: bool = False
    dry_runnable: bool = False
    destructive: bool = False
    arguments: list[ArgumentMetadata] = ...
    returns: ReturnSchema | None = None
    examples: list[Example] = ...
    flag_annotations: dict[str, FlagAnnotation] = ...

Testing

pip install "murli[dev]"
pytest

Differences from the Go implementation

murli-py targets the same wire format and principles as murli-go. Deliberate Python-specific differences:

Feature murli-go murli-py
Profile storage ~/.<tool>/profiles.json platformdirs.user_config_dir (platform-appropriate)
Profile command profile save / profile use profile set / profile set-default
Mutation guard scope Per-command (cobra wraps all) Per leaf command (Groups are routers)
Context cancellation Auto-mapped from context.Canceled Not applicable (sync Python)

License

Distributed under the MIT License.

About

Makes Python CLI tools speak natively to AI agents

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages