A pure-Go middleware for CLI tools that makes them speak natively to AI agents β with adapters for spf13/cobra, urfave/cli v2, and urfave/cli v3.
murli β named after Krishna's sacred flute in Hindu tradition. The murli's music is said to enchant every listener β each feeling it was meant for them alone.
murli the 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 feeling the output was shaped for them.
LLM-based agents interact with command-line tools differently than humans. While humans skim, agents tokenize, parse, and plan. murli acts as an automated adaptation layer, ensuring seamless developer-agent integration.
- Mode Decoupling: Automatic TTY checking. Human terminal users get pretty, formatted output; piped agent processes receive structured, clean JSON.
- Self-Documenting CLI: Dynamically inspects commands, positional arguments, and flag trees to emit detailed schemas via a persistent global
--schemaflag. - Actionable, Structured Errors: Intercepts routing, validation, and execution errors, wrapping them in JSON envelopes with dedicated exit codes and recovery suggestions to allow single-retry self-correction.
- Token Efficiency: Implements deferred logging that collapses consecutive duplicate log lines and telemetry progress indicators, saving LLM context window space. Telemetry is routed directly to
Stderr, keepingStdoutclean. - Streaming Events: Goroutine-safe NDJSON event streaming to
Stdoutfor long-running operations that produce incremental results. - Mutation Safety: Commands marked
Mutating: trueare automatically rejected in non-interactive (agent) mode, preventing accidental state changes without human confirmation.
Install the core package plus the adapter for your CLI framework:
# Core types (Writer, Logger, AgentError, Metadata)
go get github.com/allank/murli
# Pick one adapter:
go get github.com/allank/murli/cobra # spf13/cobra
go get github.com/allank/murli/cli/v2 # urfave/cli v2
go get github.com/allank/murli/cli/v3 # urfave/cli v3package main
import (
"fmt"
"github.com/allank/murli"
murliCobra "github.com/allank/murli/cobra"
"github.com/spf13/cobra"
)
type Result struct {
Path string `json:"path"`
Score float32 `json:"score"`
}
var queryCmd = &cobra.Command{
Use: "query <text>",
Short: "Semantic query search",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
writer := murliCobra.NewWriter(cmd)
writer.Progress("Searching database index...")
writer.Progress("Searching database index...") // Deduplicated automatically
writer.Flush()
results := []Result{{Path: "/docs/woodworking", Score: 0.95}}
writer.WriteSuccess(
fmt.Sprintf("Found %d matching folders", len(results)),
results,
)
return nil
},
}
func main() {
var rootCmd = &cobra.Command{Use: "riffle"}
rootCmd.AddCommand(queryCmd)
queryCmd.Flags().Int("top", 5, "Maximum results to return")
murliCobra.Annotate(queryCmd, murli.Metadata{
AgentDescription: "Searches the semantic index for directory conceptual matches.",
WhenToUse: "Use when looking for folders matching general topics.",
Idempotent: true,
Returns: &murli.ReturnSchema{
Type: "json",
Description: "Ranked list of vector similarity results",
Shape: map[string]any{"path": "string", "score": "float32"},
},
})
_ = murliCobra.Execute(rootCmd)
}package main
import (
"fmt"
"os"
"github.com/allank/murli"
murliCLI "github.com/allank/murli/cli/v2"
"github.com/urfave/cli/v2"
)
func main() {
queryCmd := &cli.Command{
Name: "query",
Usage: "Semantic query search",
Flags: []cli.Flag{
&cli.IntFlag{Name: "top", Value: 5, Usage: "Maximum results to return"},
},
Action: func(ctx *cli.Context) error {
writer := murliCLI.NewWriter(ctx)
results := []map[string]any{{"path": "/docs/woodworking", "score": 0.95}}
writer.WriteSuccess(
fmt.Sprintf("Found %d matching folders", len(results)),
results,
)
return nil
},
}
murliCLI.Annotate(queryCmd, murli.Metadata{
AgentDescription: "Searches the semantic index for directory conceptual matches.",
WhenToUse: "Use when looking for folders matching general topics.",
Idempotent: true,
})
app := &cli.App{
Name: "riffle",
Commands: []*cli.Command{queryCmd},
}
_ = murliCLI.Run(app, os.Args)
}package main
import (
"context"
"fmt"
"os"
"github.com/allank/murli"
murliCLI "github.com/allank/murli/cli/v3"
"github.com/urfave/cli/v3"
)
func main() {
queryCmd := &cli.Command{
Name: "query",
Usage: "Semantic query search",
Flags: []cli.Flag{
&cli.IntFlag{Name: "top", Value: 5, Usage: "Maximum results to return"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
writer := murliCLI.NewWriter(cmd)
results := []map[string]any{{"path": "/docs/woodworking", "score": 0.95}}
writer.WriteSuccess(
fmt.Sprintf("Found %d matching folders", len(results)),
results,
)
return nil
},
}
murliCLI.Annotate(queryCmd, murli.Metadata{
AgentDescription: "Searches the semantic index for directory conceptual matches.",
WhenToUse: "Use when looking for folders matching general topics.",
Idempotent: true,
})
app := &cli.Command{
Name: "riffle",
Commands: []*cli.Command{queryCmd},
}
_ = murliCLI.Run(app, os.Args)
}| Package | Import path | Use when |
|---|---|---|
| Core | github.com/allank/murli |
Always β provides Writer, Logger, AgentError, Metadata, and schema types |
| cobra adapter | github.com/allank/murli/cobra |
Your CLI uses spf13/cobra |
| cli/v2 adapter | github.com/allank/murli/cli/v2 |
Your CLI uses urfave/cli v2 |
| cli/v3 adapter | github.com/allank/murli/cli/v3 |
Your CLI uses urfave/cli v3 |
Each adapter provides the same surface:
| Function | cobra | cli/v2 | cli/v3 |
|---|---|---|---|
| Create writer | cobra.NewWriter(cmd) |
cli.NewWriter(ctx) |
cli.NewWriter(cmd) |
| Annotate command | cobra.Annotate(cmd, meta) |
cli.Annotate(cmd, meta) |
cli.Annotate(cmd, meta) |
| Enable + run | cobra.Execute(rootCmd) |
cli.Run(app, os.Args) |
cli.Run(app, os.Args) |
| Enable only | cobra.Enable(rootCmd) |
cli.Wrap(app) |
cli.Wrap(app) |
| Emit schema | cobra.EmitSchema(cmd) |
cli.EmitSchema(cmd, w) |
cli.EmitSchema(cmd, w) |
Running ./yourtool query --schema prints a detailed schema on Stdout. Positional argument bounds validation is automatically bypassed when generating schemas:
{
"name": "query",
"summary": "Semantic query search",
"when_to_use": "Use when looking for folders matching general topics.",
"agent_description": "Searches the semantic index for directory conceptual matches.",
"idempotent": true,
"arguments": [
{
"name": "text",
"type": "string",
"required": true,
"description": ""
}
],
"flags": [
{
"name": "top",
"type": "int",
"default": 5,
"description": "Maximum results to return"
}
],
"returns": {
"type": "json",
"description": "Ranked list of vector similarity results",
"shape": {
"path": "string",
"score": "float32"
}
}
}- Human terminal mode prints plain success/error lines:
$ ./riffle query woodworking Found 1 matching folders
- Piped or captured agent mode (or using the
--agentoverride) formats the response as a JSON envelope withschema_versionand optionaltool_version:$ ./riffle query woodworking | cat { "status": "ok", "schema_version": "0.2", "result": [ { "path": "/docs/woodworking", "score": 0.95 } ] }
Standard Go errors, routing failures, and flag parsing errors are automatically captured and formatted.
- TTY Mode:
$ ./riffle query woodworking --top abc Error: invalid argument "abc" for "--top" flag: strconv.ParseInt: parsing "abc": invalid syntax Hint: Check command usage with --schema or --help.
- Agent Mode (non-TTY):
{ "code": 1, "error": "flag_error", "message": "invalid argument \"abc\" for \"--top\" flag: strconv.ParseInt: parsing \"abc\": invalid syntax", "suggestion": "Check command usage with --schema or --help.", "recoverable": true, "schema_version": "0.2" }
Return your own structured errors using the convenience constructors or a full *murli.AgentError:
// Convenience constructors (v0.2+)
return murli.NewUserError("Query string cannot be empty", "Provide a conceptual search keyword.")
return murli.NewToolError("Database connection failed: timeout after 30s")
// Full control β set extended fields as needed
return &murli.AgentError{
Code: murli.ExitNotFound,
ErrorType: "index_missing",
Message: "Semantic index not found at ~/.riffle/index",
Suggestion: "Run `riffle index build` to create the index first.",
Recoverable: false,
DocURL: "https://example.com/docs/indexing",
}AgentError extended fields (all optional):
| Field | Type | Purpose |
|---|---|---|
ValidValues |
[]string |
Enumerable valid inputs when a bad value was supplied |
RetryAfterMs |
int |
Milliseconds to wait before retrying (use with ExitRateLimited) |
DocURL |
string |
Link to relevant documentation |
Field |
string |
Name of the specific flag or argument that caused the error |
murli standardizes exit codes to tell agents how to handle command failures:
Table-stakes (v0.1+)
| Exit Code | Constant | Meaning | Agent Action |
|---|---|---|---|
0 |
ExitOK |
Successful execution | Proceed with next task. |
1 |
ExitUserError |
Bad input or argument configuration | Read suggestion, fix parameters, and retry. |
2 |
ExitToolError |
Environment, network, or filesystem crash | Surface to user; do not retry immediately. |
3 |
ExitPartial |
Some operations succeeded, some failed | Inspect response list, retry on subset if needed. |
Extended taxonomy (v0.2+)
| Exit Code | Constant | Meaning | Agent Action |
|---|---|---|---|
4 |
ExitTimeout |
Operation timed out | Retry after a delay; the operation may be retryable. |
5 |
ExitNotFound |
Requested resource does not exist | Verify the resource exists; do not retry blindly. |
6 |
ExitPermission |
Caller lacks permission | Not retryable without an auth or config change. |
7 |
ExitConflict |
State conflict (resource already exists, etc.) | Read current state before deciding whether to retry. |
8 |
ExitRateLimited |
Rate limit hit | Wait at least retry_after_ms milliseconds before retrying. |
9 |
ExitCancelled |
Operation cancelled by signal or context | Do not retry unless the parent operation resumes. |
In agent mode, w.Log() and w.Progress() write newline-delimited JSON to Stderr. Consecutive duplicate messages are collapsed into a single entry with a repeated count, keeping agent context windows clean.
$ ./riffle index build | cat 2>logs.ndjson
# logs.ndjson contains:
{"ts":"2026-05-26T10:00:00.123Z","level":"info","msg":"Scanning /docs"}
{"ts":"2026-05-26T10:00:01.456Z","level":"progress","msg":"Indexed 500/2000 files","repeated":4}
{"ts":"2026-05-26T10:00:03.789Z","level":"info","msg":"Build complete"}In TTY mode the same calls produce plain text on Stderr, with progress lines overwriting in-place (carriage return).
For operations with measurable progress, use WriteProgress() instead of Progress():
writer.WriteProgress(murli.ProgressEvent{
Stage: "indexing",
Current: 500,
Total: 2000,
Percent: 25.0,
EtaMs: 6000,
Message: "Indexing files",
})- Agent mode β minified JSON on one line to
Stderr:{"stage":"indexing","current":500,"total":2000,"percent":25,"eta_ms":6000,"message":"Indexing files"} - TTY mode β human-readable line with carriage return to overwrite:
[indexing] Indexing files (500/2000, 25%)
All ProgressEvent fields are optional β populate what is meaningful for your operation.
Use WriteEvent() to stream incremental results to Stdout as they are produced. This is safe to call concurrently from multiple goroutines.
var wg sync.WaitGroup
for _, file := range files {
wg.Add(1)
go func(f string) {
defer wg.Done()
result := process(f)
writer.WriteEvent(result) // goroutine-safe
}(file)
}
wg.Wait()
// Call WriteSuccess or WriteError only after all WriteEvent calls complete.
writer.WriteSuccess("Processing complete", nil)Each event is written as a single minified JSON line on Stdout. WriteEvent is a no-op in TTY mode (events are machine-only).
Mark commands that write, delete, or otherwise change state with Mutating: true:
murliCobra.Annotate(deleteCmd, murli.Metadata{
AgentDescription: "Permanently deletes an index.",
WhenToUse: "Use to remove a stale or corrupt index.",
Mutating: true,
})When a mutating command runs in non-interactive (agent) mode β i.e. piped output β the adapter automatically rejects it before executing any business logic:
{
"code": 1,
"error": "confirmation_required",
"message": "This command mutates state and requires explicit confirmation.",
"suggestion": "Mutation requires confirmation. Use a TTY (interactive terminal) to run this command, or wait for --force support in a future release.",
"recoverable": true,
"schema_version": "0.2"
}This prevents agents from accidentally deleting or modifying state without human oversight. An interactive bypass (--force / --yes) is planned for v0.4.
All output envelopes carry schema_version. The success envelope also carries tool_version when set, so consumers know exactly which version of your tool produced the output.
Set murli.ToolVersion in your main() using a build-time variable:
// In main.go
var version = "dev" // overridden by -ldflags at build time
func main() {
murli.ToolVersion = version
// ...
}go build -ldflags "-X main.version=1.2.3" -o riffle .Or inject directly into the murli package at build time:
go build -ldflags "-X github.com/allank/murli.ToolVersion=1.2.3" -o riffle .When set, the success envelope includes tool_version:
{
"status": "ok",
"schema_version": "0.2",
"tool_version": "1.2.3",
"result": [...]
}go test -race ./...Distributed under the MIT License. See LICENSE for details.