murli

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 26, 2026 License: MIT Imports: 6 Imported by: 0

README

murli 🎶

Go Reference Go Report Card

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.


💡 Core Philosophy

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 --schema flag.
  • 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, keeping Stdout clean.
  • Streaming Events: Goroutine-safe NDJSON event streaming to Stdout for long-running operations that produce incremental results.
  • Mutation Safety: Commands marked Mutating: true are automatically rejected in non-interactive (agent) mode, preventing accidental state changes without human confirmation.

🛠️ Installation

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 v3

🚀 Quick Start

cobra
package 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)
}
urfave/cli v2
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)
}
urfave/cli v3
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 Structure

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)

📖 Key Features

1. Dynamic JSON Schema (--schema)

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"
    }
  }
}
2. Output & TTY Decoupling
  • Human terminal mode prints plain success/error lines:
    $ ./riffle query woodworking
    Found 1 matching folders
    
  • Piped or captured agent mode (or using the --agent override) formats the response as a JSON envelope with schema_version and optional tool_version:
    $ ./riffle query woodworking | cat
    {
      "status": "ok",
      "schema_version": "0.2",
      "result": [
        {
          "path": "/docs/woodworking",
          "score": 0.95
        }
      ]
    }
    
3. Bulletproof Error Handling

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
4. Exit Code Mapping

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.
5. NDJSON Log Output (v0.2+)

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).

6. Structured Progress Events (v0.2+)

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.

7. NDJSON Event Streaming (v0.2+)

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).

8. Mutation Safety (v0.2+)

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.

9. Version Stamps (v0.2+)

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": [...]
}

🧪 Testing

go test -race ./...

📄 License

Distributed under the MIT License. See LICENSE for details.

Documentation

Index

Constants

View Source
const (
	ExitOK        = 0 // Success
	ExitUserError = 1 // Bad input or arguments
	ExitToolError = 2 // Environment/internal failure
	ExitPartial   = 3 // Some operations succeeded, some failed
)

Exit codes 0–3: table-stakes (present since v0.1).

View Source
const (
	ExitTimeout     = 4 // Operation timed out; retry may succeed
	ExitNotFound    = 5 // Requested resource does not exist
	ExitPermission  = 6 // Caller lacks permission; not retryable without auth change
	ExitConflict    = 7 // State conflict (e.g. resource already exists)
	ExitRateLimited = 8 // Rate limit hit; retry after RetryAfterMs
	ExitCancelled   = 9 // Operation cancelled by signal or context
)

Exit codes 4–9: extended taxonomy (v0.2).

View Source
const SchemaVersion = "0.2"

SchemaVersion is the murli envelope schema version. Frozen at "1.0" once v1.0 ships; incrementing requires a documented migration.

Variables

View Source
var ExitFunc = os.Exit

ExitFunc is swapped out in tests to capture exit codes without terminating.

View Source
var ToolVersion = ""

ToolVersion is the version of the CLI tool using murli. Set this in your main() using a version variable injected at build time:

murli.ToolVersion = version // where `version` is set via -ldflags "-X main.version=1.2.3"

Or inject directly at build time:

-ldflags "-X github.com/allank/murli.ToolVersion=1.2.3"

Functions

This section is empty.

Types

type AgentError

type AgentError struct {
	Code          int      `json:"code"`
	ErrorType     string   `json:"error"`
	Message       string   `json:"message"`
	Suggestion    string   `json:"suggestion,omitempty"`
	Recoverable   bool     `json:"recoverable"`
	ValidValues   []string `json:"valid_values,omitempty"`
	RetryAfterMs  int      `json:"retry_after_ms,omitempty"`
	DocURL        string   `json:"doc_url,omitempty"`
	Field         string   `json:"field,omitempty"`
	SchemaVersion string   `json:"schema_version,omitempty"`
	ToolVersion   string   `json:"tool_version,omitempty"`
}

AgentError is the structured error envelope written to stderr. Fields are serialised as JSON in agent mode; SchemaVersion and ToolVersion are auto-populated by WriteError — do not set them manually.

func NewToolError

func NewToolError(message string) *AgentError

NewToolError returns a non-recoverable AgentError for internal/environment failures. Use when the fault is in the environment (network, filesystem, dependency) not the caller.

func NewUserError

func NewUserError(message, suggestion string) *AgentError

NewUserError returns a recoverable AgentError for bad input. Use when the caller supplied invalid arguments or configuration.

func (*AgentError) Error

func (e *AgentError) Error() string

Error implements the standard Go error interface.

type ArgumentMetadata

type ArgumentMetadata struct {
	Name        string `json:"name"`
	Type        string `json:"type"`
	Required    bool   `json:"required"`
	Description string `json:"description"`
}

ArgumentMetadata documents a single positional argument.

type CommandSchema

type CommandSchema struct {
	Name             string             `json:"name"`
	Summary          string             `json:"summary"`
	WhenToUse        string             `json:"when_to_use,omitempty"`
	AgentDescription string             `json:"agent_description,omitempty"`
	Idempotent       bool               `json:"idempotent"`
	Arguments        []ArgumentMetadata `json:"arguments,omitempty"`
	Flags            []FlagSchema       `json:"flags,omitempty"`
	Returns          *ReturnSchema      `json:"returns,omitempty"`
	Examples         []string           `json:"examples,omitempty"`
	Subcommands      []SubcommandSchema `json:"subcommands,omitempty"`
}

CommandSchema is the JSON payload emitted by --schema.

type FlagSchema

type FlagSchema struct {
	Name        string `json:"name"`
	Type        string `json:"type"`
	Default     any    `json:"default"`
	Description string `json:"description"`
}

FlagSchema represents a single CLI flag in the JSON schema.

type Logger

type Logger struct {
	// contains filtered or unexported fields
}

Logger writes diagnostic messages to stderr. TTY mode: human-readable plain text. Agent mode: one NDJSON object per line {ts, level, msg}; consecutive duplicates are collapsed into a single entry with a "repeated" count.

func NewLogger

func NewLogger(writer io.Writer, isTTY bool) *Logger

NewLogger initializes a Logger writing to writer.

func (*Logger) Flush

func (l *Logger) Flush()

Flush writes any deferred/deduplicated log entry.

func (*Logger) Log

func (l *Logger) Log(line string)

Log writes a message. In TTY mode: plain text. In agent mode: NDJSON with deduplication.

func (*Logger) LogProgress

func (l *Logger) LogProgress(line string)

LogProgress writes a progress message. TTY: overwrites current line with carriage return. Agent: NDJSON with level "progress" and deduplication.

type Metadata

type Metadata struct {
	// AgentDescription is a detailed description of the command's scope.
	AgentDescription string `json:"agent_description"`

	// WhenToUse specifies when an agent should select this command.
	WhenToUse string `json:"when_to_use"`

	// Idempotent specifies if the command is safe to re-run on failure.
	Idempotent bool `json:"idempotent"`

	// Mutating marks commands that write, delete, or otherwise change state.
	// When true and the output is not a TTY, the adapter rejects the command with a
	// confirmation_required error to prevent accidental mutation in non-interactive mode.
	// A bypass flag (--force / --yes) will be added in a future release.
	Mutating bool `json:"mutating,omitempty"`

	// Arguments defines explicit positional argument documentation.
	Arguments []ArgumentMetadata `json:"arguments,omitempty"`

	// Returns defines the expected output format on success.
	Returns *ReturnSchema `json:"returns,omitempty"`

	// Examples contains concrete shell invocations for agent in-context learning.
	Examples []string `json:"examples,omitempty"`
}

Metadata provides LLM-specific parameters to supplement standard CLI command definitions.

type ProgressEvent

type ProgressEvent struct {
	Stage   string  `json:"stage,omitempty"`
	Current int     `json:"current,omitempty"`
	Total   int     `json:"total,omitempty"`
	Percent float64 `json:"percent,omitempty"`
	EtaMs   int64   `json:"eta_ms,omitempty"`
	Message string  `json:"message,omitempty"`
}

ProgressEvent carries typed progress state for long-running operations. All fields are optional — populate what is meaningful for the operation.

type ReturnSchema

type ReturnSchema struct {
	Type        string         `json:"type"`
	Description string         `json:"description"`
	Shape       map[string]any `json:"shape,omitempty"`
}

ReturnSchema describes the shape of successful command output.

type SubcommandSchema

type SubcommandSchema struct {
	Name    string `json:"name"`
	Summary string `json:"summary"`
}

SubcommandSchema represents a registered subcommand.

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer handles dynamic output routing based on terminal presence and agent flags.

func NewWriter

func NewWriter(stdout, stderr io.Writer, agentMode bool) *Writer

NewWriter returns a configured output writer. Set agentMode true to force JSON output regardless of TTY state.

func (*Writer) Flush

func (w *Writer) Flush()

Flush flushes any deferred deduplicated logs.

func (*Writer) IsTTY

func (w *Writer) IsTTY() bool

IsTTY returns true if the writer is in human (TTY) mode.

func (*Writer) Log

func (w *Writer) Log(msg string)

Log writes a message to stderr, deduplicating consecutive duplicates in agent mode.

func (*Writer) Progress

func (w *Writer) Progress(msg string)

Progress writes a progress update; overwrites current line in TTY, collapses in agent mode.

func (*Writer) WriteError

func (w *Writer) WriteError(err *AgentError)

WriteError writes the structured error to stderr and exits with the error's exit code. In TTY mode: human-readable. In agent mode: JSON envelope with schema_version and tool_version.

func (*Writer) WriteEvent

func (w *Writer) WriteEvent(v any)

WriteEvent writes a single minified JSON object to stdout on one line. It is safe to call WriteEvent concurrently from multiple goroutines. However, WriteSuccess and WriteError are not mutex-protected — call them only after all WriteEvent goroutines have completed (e.g., after a sync.WaitGroup.Wait()). In TTY mode WriteEvent is a no-op (events are machine-only).

func (*Writer) WriteProgress

func (w *Writer) WriteProgress(evt ProgressEvent)

WriteProgress emits a structured progress event to stderr. Agent mode: minified JSON on one line (via json.Marshal, consistent with WriteEvent). TTY mode: formatted human-readable line with carriage return to overwrite.

func (*Writer) WriteSuccess

func (w *Writer) WriteSuccess(humanText string, jsonPayload any)

WriteSuccess writes to stdout. TTY mode writes humanText; agent mode writes a JSON envelope with status, schema_version, tool_version (if set), and result.

Directories

Path Synopsis
cli
v2
v3

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL