Lets an agent type-check a DSL expression before spending a full input
pass. `mlr put --explain '...'` (and filter) runs the existing
parse -> ValidateAST -> CST build -> Resolve path, then:
- valid: prints "mlr {put,filter}: DSL expression is valid." and exits 0
- invalid: returns the build error up the normal path, so --errors-json
emits a structured document; exits 1
- -W with fatal warnings: reports and exits 1
The gate lives in the pass-two constructor, before any input file is
opened, so no input stream is read (verified with a nonexistent input
file still validating OK).
Also categorize bare "parse error: ..." messages from the DSL parser as
kind "dsl-parse-error" rather than "generic" (climain/errors_json.go),
so --explain --errors-json gives an agent a useful error kind. The CSV
reader's "parse error on line ..." is stream-time and never reaches this
command-line-parse categorizer.
Tests: dsl-explain/0001-0004 regression cases (valid put/filter, invalid
plain, invalid --errors-json) and categorize unit tests. Regenerated
verb docs, manpage, and the help usage-verbs golden case.
The older -X ("exit after parsing") still exits 0 even on a parse error;
left as-is since --explain is the correct validation path.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
15 KiB
Roadmap: making Miller more AI-friendly (issue #2098)
This is a living roadmap for making Miller drivable by an LLM agent, derived from issue #2098 and @aborruso's comment on it. Each PR section below is self-contained so that a future PR can be opened against it. Update status as work lands.
Context
Miller already has near-complete introspection coverage (mlr help topics:
verbs, functions, keywords, flags, exact/approximate search). The gap for agents
is shape, not coverage: nearly everything is emitted as human prose via
fmt.Printf, so an agent must scrape text and ends up hallucinating flags and
signatures — the highest-volume failure mode. The arc below moves Miller's
introspection surface from prose to a stable, parseable structure, then builds
operability (self-correction, validation, an MCP server) on top of it.
Two tracks, per the issue:
- Discoverability — how an agent learns what Miller can do (structured help catalog, capability index/router, worked-example corpus).
- Operability — how an agent runs Miller and self-corrects (structured
errors, a DSL validate/dry-run, a
describeschema verb, an MCP server).
Grounding facts (verified in the codebase)
- Help dispatch is name-based string matching with no flag parsing.
HelpMain(args []string)(pkg/terminals/help/entry.go:232) stripshelp, special-casesfind, then matchesargs[0]againsthandlerLookupTable(entry.go:254-276); unmatched falls through to exact/approximate search (entry.go:279-281). Handlers arezaryHandlerFunc/varArgHandlerFunc(entry.go:43-55). An--as-jsonmodifier must therefore be extracted from args before dispatch, not parsed by an existing flag layer. - All catalog structs use private (lowercase) fields and have no JSON tags:
BuiltinFunctionInfo—pkg/dsl/cst/builtin_function_manager.go:42(name, class, help, examples, arity fields). Registry:BuiltinFunctionManagerInstance; accessorsLookUp,GetBuiltinFunctionNames,ListBuiltinFunctionsInClass.Flag/FlagSection/FlagTable—pkg/cli/flag_types.go:66,78,86(name, altNames, arg in curly-brace notation{a,b,c}, help, parser, suppressFlagEnumeration). AccessorsGetFlagNames,ListFlagsForSection,FlagTakesArg.TransformerSetup—pkg/transformers/aaa_record_transformer.go:52(Verb, UsageFunc, ParseCLIFunc, IgnoresInput). RegistryTRANSFORMER_LOOKUP_TABLE(aaa_transformer_table.go); accessorsLookUp,GetVerbNames,ShowHelpForTransformer.- Keywords —
KEYWORD_USAGE_TABLEof{name, usageFunc}(pkg/dsl/cst/keyword_usage.go:11-74); help lives inside the func bodies. - Consequence: serialization needs exported DTO/"view" structs populated from these registries — we cannot just add JSON tags to private fields, and we should not export the internals (this keeps the wire shape decoupled and versionable).
- Verb usage and keyword help write directly to the terminal. Each verb
hand-writes
UsageFunc(*os.File)thatPrintfs its options (e.g.pkg/transformers/cat.go:22); keywordusageFunc()prints to stdout (pkg/dsl/cst/keyword_usage.go). We refactor these sinks rather than hijacking the file descriptor: changeTransformerUsageFuncand the keyword usage funcs to take anio.Writer, with existing callers passingos.Stdout. A buffer then collects the same text cleanly, with no pipe/redirect tricks. Verb options remaining prose-only is the Tier-1/Tier-2 dividing line. FLAG_TABLE.NilCheck()(pkg/cli/flag_types.go:310) is the existing build-time completeness pattern (exercised via amlr helpentrypoint + a regression test). We mirror it to track verb-option migration in PR3.
Cross-cutting design (applies to all PRs)
- DTO layer. Add exported view structs in a new package (proposed
pkg/terminals/help/catalog/orpkg/help/catalog/) — e.g.Catalog,FunctionInfo,FlagInfo,VerbInfo,KeywordInfo,OptionSpec— each with explicitjson:"..."tags (snake_case). Populate them from the existing registries via the accessors above. Internal structs stay private; the DTO is the stable wire contract. - Versioning / cache keys. Every full/partial JSON document carries
top-level
mlr_version(from the same source asmlr version) andcatalog_schema_version(an integer bumped on shape changes). Miller is a static binary, so the catalog changes only when the binary does — these make the dump a perfect cache key for an MCP server or any tool (re-fetch only on a binary/schema bump; no TTLs). - Opt-in. Two equivalent ways to ask for JSON, neither spelled
--json(that top-level flag already means JSON I/O format):- Per-call flag
--as-json— used inside thehelpnamespace, where it is unambiguous (e.g.mlr help --as-json,mlr help verb cat --as-json). - Env var
MLR_HELP_JSON(truthy) — a set-once global so an agent opts in once rather than per-call.--as-jsonand a truthyMLR_HELP_JSONare equivalent; the flag wins if both are present. Centralize the "should I emit JSON?" decision in one helper.
- Per-call flag
- Output discipline. JSON goes to stdout, one document per invocation, no colorization, deterministic key/element ordering (sort by name) so diffs and agent parsing are stable.
- Examples never rot. Worked examples surfaced in the catalog are
CI-tested; aim for a runnable example on every verb (not just functions)
— an agent pattern-matches off
mlr cat -n -g shapefaster than off prose. Hook into the existing regression-test / docs-build machinery.
PR 1 — Tier 1: mlr help --as-json machine-readable catalog (foundation)
Goal. One call yields a structured, parseable model of Miller's entire
surface; per-item --as-json for targeted fetches. Plain (no---as-json)
output is byte-for-byte unchanged. Everything downstream builds on this.
Surface.
mlr help --as-json— full catalog as one JSON document.mlr help verb cat --as-json— one or more verbs.mlr help function splitax --as-json— one or more functions.mlr help flag --ifs --as-json— one or more flags.mlr help keyword ENV --as-json— one or more keywords.- A truthy
MLR_HELP_JSONmakes all of the above emit JSON without the flag.
Shape (Tier 1).
mlr_version,catalog_schema_versionat top level.- Functions:
name,class,help,examples[], arity info, and a structured signature{params: [{name, type}], return: type}— see the signature note below. - Flags:
section,name,alt_names[],arg,help. - Verbs:
name,summary(one line),ignores_input, andusage_text(the verb's renderedUsageFuncoutput) as the Tier-1 fallback for not-yet-structured options. - Keywords:
name,helptext.
Implementation.
- New DTO package (cross-cutting #1).
- Render verb usage via an
io.Writer, not a captured fd. ChangeTransformerUsageFunc(and the keyword usage funcs) to takeio.Writer; existing callers passos.Stdout, and the catalog builder passes abytes.Bufferto collectusage_text/ keyword help. This is the "right place" refactor — no pipe/os.Filehijacking. Touch theTransformerUsageFunctypedef (aaa_record_transformer.go), the dispatch inaaa_transformer_table.go:85, every verb'sUsageFunc, and the keyword usage funcs (keyword_usage.go). Mechanical but broad. - Structured function signatures (go deeper, don't parse prose). Rather than
scraping the human first-line, derive
{params, return}from the function-info API inbuiltin_function_manager.go: the arity fields (hasMultipleArities,minimum/maximumVariadicArity) plus the typed func pointers (unaryFunc,binaryFunc, …) already encode arity/shape. Add accessor(s) onBuiltinFunctionInfothat expose this as structured data and feed the DTO. Keep the human first-line inhelptoo. --as-jsonextraction: inHelpMain(entry.go:232), scan/strip--as-json(and consultMLR_HELP_JSON) before the name-based dispatch (entry.go:254); thread awantJSON boolinto the per-topic handlers. Add a builder that walks all four registries for the no-arg full-dump case.- Reuse the registry accessors listed in Grounding facts; no registry refactor.
Tests. Golden-JSON regression cases under the existing regression harness; a
schema-completeness test (every function/flag/verb/keyword appears; required
fields non-empty) in the spirit of NilCheck.
PR 2 — Discovery: JSON index + capability router
Goal. Cheap first calls so an agent can choose before drilling in.
mlr help --as-json --index→[{kind, name, summary}]across verbs, functions, flags, keywords — names + one-line summaries only, no bodies/examples/usage_text. (Delta over existinglist-verbs/list-functions, which are names-only.) Reuse the summary extraction from PR1. This is the cheap first call that lets an agent pick a verb before fetching its full entry.mlr which "join two files on a key"→ ranked JSON[{verb, score, summary}]. Build on Miller's existing exact/approximate help search (helpByApproximateSearchOneand the*Approximate*accessors inentry.go). Signal confidence via exit code (e.g.0confident match,2no confident match) so the agent branches on status, not prose.mlr whichis the reverse of--index(intent → verb vs. browse-all), short-circuiting the common "which verb?" round-trip.
Tests. Index covers every catalog item; which returns the expected top
verb + exit code for a handful of canonical intents.
PR 3 — Tier 2: structured verb options (+ enum value-sets)
Goal. Replace each verb's usage_text blob with a structured option list;
verbs upgrade independently.
Model.
- Add optional
Options []OptionSpectoTransformerSetup(aaa_record_transformer.go:52), defaultnil. OptionSpec:{Flag, Arg, Type, Desc string; Repeatable bool; Values []string}.Typeis a small enum:bool | string | int | float | csv-list | regex | filename | format | enum.- Finite domains emit their value set: where an option has a fixed domain
(e.g. output format), set
Type:"enum"and populateValues(e.g.["csv","tsv","json","jsonl","pprint","markdown","dkvp","nidx","xtab"]). Agents hallucinate values, not just flags — emitting the actual enum attacks value-hallucination at the source. - Scope: static domains only.
Valueshere is @aborruso's codelist — the set fixed by the binary (output formats, compression types). His constraint case — values that are only valid given the current input (e.g. a field name for-g) — is data-dependent and out of scope for the static catalog; that belongs tomlr describe(PR6), which reads the input schema. Keep the line clean: PR3 enums are binary-fixed, never data-derived.
Emitter. Prefer Options when non-nil; otherwise fall back to usage_text.
Agents always get something; no big-bang migration. Optionally render each
verb's UsageFunc from Options so prose and JSON stay in sync.
(Done post-migration: WriteVerbOptions in aaa_verb_usage.go renders each
usage message's "Options:" block from the specs; all 70 verbs migrated.)
Migration tracking. Add a VerbOptionsNilCheck mirroring
FLAG_TABLE.NilCheck() (flag_types.go:310) wired through a mlr help
entrypoint (entry.go) and asserted in a regression test: report which verbs
still have Options == nil. Migrate verbs incrementally here and in follow-ups.
PR 4 — Structured errors: --errors-json
Goal. Agents branch on error kind instead of regex-matching English; the catalog becomes the dictionary errors resolve against. (Biggest operability win per the issue.)
--errors-jsonemits{error, kind, verb, position, hint, did_you_mean[]}.did_you_mean: Levenshtein nearest-match over verb/flag/function/keyword names from the PR1 catalog — closing the self-correction loop the catalog enables.hintanddid_you_meanare copy-pasteable corrected command lines, not prose (e.g.mlr cut -f x,y -- file.csv) — agents recover from a command far faster than from a sentence describing the fix.- Identify Miller's central CLI/DSL error-emission points and route them through
a structured-error type when the flag (or the
MLR_HELP_JSON-style global) is set.
PR 5 — DSL --explain / validate dry-run (landed)
Goal. Validate/type-check a DSL expression before spending a full input pass (a big context saver for agents).
mlr put --explain '...'(andmlr filter --explain) parse + type-check the DSL, report errors (ideally via the PR4 structured-error path), and exit without consuming the full input stream.- Reuse the existing DSL parse/CST build path; gate it before the record loop.
Landed. --explain added to put/filter (put_or_filter.go): after the
existing cstRootNode.Build (which already does parse → ValidateAST → CST build
→ Resolve), a valid expression prints mlr {put,filter}: DSL expression is valid. and exits 0; an invalid one returns the build error up the normal path,
so --errors-json yields a structured document. The gate sits in the pass-two
constructor, before any input file is opened, so no input is read. DSL parser
messages (parse error: ...) now categorize as dsl-parse-error rather than
generic (climain/errors_json.go). Tests: dsl-explain/0001-0004 regression
cases (valid put/filter, invalid plain, invalid --errors-json) plus categorize
unit tests. Note: the older -X ("exit after parsing") still exits 0 even on a
parse error — a pre-existing quirk left as-is since --explain is the correct
validation path.
PR 6 — mlr describe schema/shape introspection
Goal. Let an agent learn the data's shape, complementing the catalog's tool shape.
mlr describe(verb or terminal) reports field names, inferred types, and cardinality over the input stream, with an--as-jsonform.- Leverage Miller's existing type-inference (
pkg/mlrval) and field-collection machinery; likely a new verb inpkg/transformers/.
PR 7 — MCP server + Agent Skill (the loop)
Goal. Package the above so an agent gets both the surface and the loop.
- Thin MCP tool-server wrapping the catalog and friends:
list_capabilities(PR1/PR2),validate_dsl(PR5),describe_data(PR6),run.list_capabilitiescaches the dump keyed onmlr_version(PR1). - Ship an Agent Skill / playbook encoding the discover → constrain → validate → run loop — the recipe is what makes a CLI "shine when driven by an agent," beyond the raw tool surface.
Open questions (carry into the relevant PR; not blocking the roadmap)
- Env-var scope:
MLR_HELP_JSONflips help/catalog output. Should the same (or a broaderMLR_AGENT) env var also flip--errors-jsonon, so an agent sets one variable for both? (Decide when PR4 lands.) mlr help schemaalias for the full dump, in addition to the--as-jsonflag? (Distinct from publishing a JSON Schema describing the catalog document, which the exported Go DTOs already serve as a de-facto version of.)
Resolved: the per-call flag is --as-json (with MLR_HELP_JSON as the env-var
equivalent); function signatures are emitted structurally from the function-info
API (PR1), not parsed from prose.