Skip to content

feat(generate): add json-schema for a CLI's config file - #839

Open
jdx wants to merge 1 commit into
agent/config-docsfrom
agent/config-schema
Open

feat(generate): add json-schema for a CLI's config file#839
jdx wants to merge 1 commit into
agent/config-docsfrom
agent/config-schema

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

usage g json-schema generates a draft 2020-12 JSON Schema for the config file a spec's config block describes. Fourth PR of stack #836; depends on the vocabulary in #835.

Every CLI in the fleet has one of these. mise generates schema/mise.json from its registry with a 200-line TypeScript script; the others hand-write theirs or go without. Generating it from the spec means the schema, the settings documentation and (eventually) the resolver cannot disagree about what a setting is.

$ usage g json-schema -f mise.usage.kdl --url https://mise.jdx.dev/schema/mise.json

What it maps

  • Dotted keys re-nest into the shape a file actually has — task.cache.remote_mode becomes three levels of object. This is what the flat spelling buys: one canonical key in the spec, the file's real shape reconstructed here.
  • The type grammar onto what a validator understands: uintinteger with minimum: 0, set<T> → array with uniqueItems, map<K,V>additionalProperties, urlformat: uri, unions → anyOf, option<T> → simply not required. A type only the tool knows (x-carried Rust types, Base::Custom) validates as a string, which is the most a schema can say without knowing more.
  • The editor-facing facts from the same declarations the docs page renders: default, enum from choices, deprecated: true, and long_help as the description so hover shows the whole story.

unevaluatedProperties: false on every object, so a typo'd key is reported rather than silently ignored.

Two decisions about what a config file can hold

Hidden props stay in. hide governs documentation and completions; the setting is still settable. With unevaluatedProperties: false, omitting one turns an editor red on a file that is perfectly legal. mise agrees — ci is hide = true and is in schema/mise.json.

scope="env" props are left out. Such a setting cannot be read from a file at all, so listing it advertises a key the CLI will ignore there. mise's schema.ts does list its five env_only settings — system_config_file, global_config_root, and three more — so mise.json today tells your editor those are valid mise.toml keys. They aren't. This is a deliberate divergence from the parity target, and an instance of exactly the drift that generating from one declaration removes.

A key declared as both a value and a group (prop "a" beside prop "a.b") is a contradiction no schema can express. The group wins: a scalar type left beside properties would reject every value rather than just that one.

Verification

Six tests, each mutation-checked — reverting the behavior makes the test fail, including the two new ones (the scope="env" filter and the value/group collision). A third mutation showed the reverse-order collision guard was unreachable, because props is a BTreeMap and a key always sorts before every key extending it, so that branch was removed rather than left untested.

Beyond the unit tests, run against a hand-converted slice of mise's registry — the conversion an adoption PR would do — covering bools, uint with a default, map<string, string>, option<bool>, the bool|string union with its four documented choices, a deprecated prop with renamed_to, and task.cache.remote_mode. Output diffed key-by-key against schema/mise.json: same types, same defaults, same enums. The differences are the two above plus anyOf where schema.ts writes oneOf (equivalent for disjoint types).

cargo test --workspace --all-features green (48 binaries), clippy --all-targets -D warnings clean, mise run render applied.

Also classifies the new command in command_effectsread, raised to write by --out-file — which the coverage test in that module required.

Noticed in passing and deliberately left alone: the generated "Source code" link for every multi-word command points at a hyphenated filename that does not exist (complete-word.rs, completion-init.rs). Pre-existing, unrelated to config, and getting its own change.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.


Note

Low Risk
Read-only generator with optional file output; no runtime config loading or auth changes. Main impact is generated schema semantics (env scope, nesting) affecting editor validation.

Overview
Adds usage generate json-schema (usage g json-schema) to emit a draft 2020-12 JSON Schema for the config file described by a usage spec’s config block, with optional --title, --url ($id), and --out-file (including - for stdout).

A new cli/src/schema module maps spec config props to schema: dotted keys nest into objects, the config type grammar becomes JSON Schema types, and metadata (default, choicesenum, deprecated, help text) is carried through. Schemas use unevaluatedProperties: false; scope="env" props are omitted; hidden props stay. The CLI errors when there are no file-holdable properties (e.g. all env-only).

Wiring includes the generate subcommand, command_effects (read; write with --out-file), regenerated Fig/manpage/docs, and unit plus integration tests.

Reviewed by Cursor Bugbot for commit 0079a6d. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 85ccbefa-245e-42c3-b75f-9bb7594d43f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread cli/src/cli/generate/json_schema.rs
Comment thread cli/src/schema/mod.rs
Comment thread cli/src/cli/generate/json_schema.rs Outdated
@jdx
jdx marked this pull request as ready for review August 12, 2026 22:58
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a JSON Schema generator for config blocks and integrates it into the CLI, command-effect metadata, generated documentation, and tests.

  • Maps config types, choices, defaults, descriptions, deprecation metadata, and dotted keys into draft 2020-12 JSON Schema.
  • Excludes environment-only settings and rejects generation when no file-backed settings remain.
  • Adds the generate json-schema command, output options, documentation, and test coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported empty-schema paths are fixed because environment-only properties are filtered before dotted groups are constructed and the command checks the resulting schema.

Important Files Changed

Filename Overview
cli/src/schema/mod.rs Implements schema construction, nested-key handling, type conversion, environment-scope filtering, and focused unit tests without an accepted blocking issue.
cli/src/cli/generate/json_schema.rs Adds the generator entrypoint and correctly checks the generated top-level properties after environment-only settings have been filtered.
cli/tests/json_schema.rs Exercises CLI output and rejection of configurations with no file-backed settings.
cli/src/command_effects.rs Classifies schema generation as read-only unless an output file is supplied.
cli/src/cli/generate/mod.rs Wires the new generator into the existing command dispatch and shared output path.

Fix All in Greploop

Reviews (13): Last reviewed commit: "feat(generate): add json-schema for a CL..." | Re-trigger Greptile

Comment thread cli/src/cli/generate/json_schema.rs Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁▁▁▂▂▂█ 152,897,001 → 175,048,479 +14.49% ⚠️ 14.41 → 16.16ms +12.20%
startup ▁▁▁▁▁▁▁▁▁▁▁▁█ 1,201,844 → 1,221,837 +1.66% ⚠️ 0.97 → 0.96ms -0.70%

2 benchmark(s) above the 1% gate: markdown +14.49%, startup +1.66%

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

usage clap ratio
instructions, cold parse 29823 5960254 199x
usage: argv -> struct                             856 ns      0.86 µs
clap: build tree + parse -> struct             499981 ns    499.98 µs
clap: parse -> struct, tree reused              24000 ns     24.00 µs
clap: build tree only                          311604 ns    311.60 µs

0079a6db3e51 vs ebea8955d430 · measured on the runner, not pushed to the history.

@jdx
jdx force-pushed the agent/config-schema branch from 67064cd to fd5de37 Compare August 12, 2026 23:07

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

All three Bugbot findings were real. Fixed:

--out-file - wrote a file named -, and stdout could panic. I hand-rolled the write instead of using generate::write_or_stdout, which every sibling generator goes through — it takes - for stdout and reports a broken pipe rather than panicking on one, which matters because these documents are big enough to outlast a pipe buffer. Now it uses the shared writer. A test asserts no file named - appears in the working directory, and it took two attempts to make honest: my first mutation of it silently didn't apply (shell escaping ate a \n in the search string), so the test appeared to pass a mutation it had never actually seen. Asserting the match before mutating showed the test does catch it.

enum on a composite type rejected every possible value. Correct and subtle: choices says what one value may be, so on a list<string> an enum at the top of the array schema is matched against the whole array — which no array can satisfy. The enum now goes on items for a list or set, on additionalProperties for a map, and stays at the top level for a scalar.

An all-env-only spec still emitted the rejecting schema. The bail checked props before the scope="env" filter ran, so a spec whose settings are all env-only produced {"properties": {}, "unevaluatedProperties": false} — a schema that rejects every config file there is. The check now asks the generated schema rather than the spec, which is the only place that knows what survived filtering.

Three tests, each mutation-verified; two of them at the command level in a new cli/tests/json_schema.rs, since two of the three bugs only exist as behavior of the command.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx force-pushed the agent/config-schema branch 2 times, most recently from a9bf1c3 to 08ac0ea Compare August 12, 2026 23:59
Comment thread cli/src/schema/mod.rs
@jdx
jdx force-pushed the agent/config-schema branch from 08ac0ea to 6ba7e7d Compare August 13, 2026 00:20

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Real, and my fix for the value-vs-group collision was too blunt: I replaced any parent that had no properties, and a map or object parent has none either — it has additionalProperties, or a bare type: object. Overwriting it dropped exactly the keyword that let the map hold anything, and unevaluatedProperties: false then rejected every key the map was declared to accept.

Now only a parent that cannot hold keys at all is replaced, so map<string, string> beside vars.known keeps both its additionalProperties and its named key, and a free-form object parent is not quietly made strict. Mutation-verified in both directions — replacing too much fails the new test, replacing nothing fails the original one.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread cli/src/schema/mod.rs
@jdx
jdx force-pushed the agent/config-schema branch from 6ba7e7d to 1167b49 Compare August 13, 2026 01:00

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Right, and it is the same class as the list/map case with a different shape: anyOf and enum are combined with AND, so string-only choices on a bool|string setting rejected true — a value the declared type plainly allows. An editor would flag legal config.

The fix isn't to move the enum somewhere (there is no items to move it to) but to decide which of the two is authoritative. A spec that lists its choices has said what the accepted values are, and each choice carries its own type — mise's python.uv_venv_auto is exactly this, a bool|string whose four choices include both booleans. So for a union the enum replaces the union rather than intersecting with it; a union with no choices still describes itself as a union.

Mutation-verified in both directions.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx force-pushed the agent/config-schema branch from 1167b49 to 311e564 Compare August 13, 2026 01:07
@jdx
jdx force-pushed the agent/config-schema branch from 311e564 to badb54a Compare August 13, 2026 02:38
Comment thread cli/src/schema/mod.rs
@jdx
jdx force-pushed the agent/config-schema branch from badb54a to e734d80 Compare August 13, 2026 03:27

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Right, and it is my own fix from the last round not going far enough: I descended one level to items/additionalProperties, so map<string, list<string>> put the enum on the array under additionalProperties, where it was AND-combined with type: array and nothing could satisfy it.

It now descends all the way to the scalars, and the union case is keyed on whether it descended at all rather than on which key it found. Mutation-verified: descending only one level fails the test.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

Comment thread cli/src/cli/generate/json_schema.rs

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e734d80. Configure here.

Comment thread cli/src/schema/mod.rs Outdated
@jdx
jdx force-pushed the agent/config-schema branch from e734d80 to 5e4bed3 Compare August 13, 2026 03:39

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Two findings; one real, one I could not reproduce.

Choices stripping container union branches — real, and my previous fix was the cause: I removed anyOf whenever the walk had not descended, and a string|list<string> exposes no items at the top, so the union was thrown away and every list form the type allows became invalid.

The rule is now the general one instead of a special case: the enum is applied to every position a single value can occupy — down through items/additionalProperties, and across each anyOf branch, recursively. So bool|string gets the choices in both branches (which is what makes mise's four-value python.uv_venv_auto validate), and string|list<string> gets them on the string branch and on the array branch's items. Nothing is stripped, and no case needs to be enumerated.

Dotted env-only groups bypassing the guard — I could not reproduce this, and I think the reading is off by one step. The scope="env" filter is the first thing in the loop, before any group is created, so an intermediate object never comes into existence for a filtered leaf. Checked several shapes:

$ usage g json-schema --spec '… prop "remote.cache" type="bool" scope="env" …'
Error: this spec declares nothing a config file can hold, so there is no schema to write

$ … prop "a.b.c" type="bool" scope="env"          # same error
$ … prop "a.b" scope="env" + prop "keep"          # writes a schema with `keep` only

If there is a shape that does produce an empty group I would rather fix it than argue, so I am happy to be shown one.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx force-pushed the agent/config-schema branch from 5e4bed3 to 38a7d7a Compare August 13, 2026 03:53
@jdx
jdx force-pushed the agent/config-schema branch from 38a7d7a to 34b6aeb Compare August 13, 2026 04:02
`usage g json-schema` writes a draft 2020-12 schema for the config file a
spec's `config` block describes: dotted keys re-nested into the shape a file
actually has, the type grammar mapped onto what a validator understands, and
the editor-facing facts — default, enum, deprecated, description — taken from
the same declarations the docs page renders.

`unevaluatedProperties: false` on every object, so an unknown key is reported
rather than ignored.

Two decisions worth stating, both about what a config file can hold:

- Hidden props stay in the schema. `hide` is about documentation and
  completion; the setting is still settable, and omitting it from a schema
  that rejects unknown keys would turn an editor red on a legal file.
- `scope="env"` props are left out. Such a setting cannot be read from a file
  at all, so listing it would advertise a key the CLI ignores there. mise's
  schema.ts does list its five `env_only` settings — a deliberate divergence
  from the parity target, and an instance of the drift generating this from
  one declaration is meant to remove.

A key declared as both a value and a group (`prop "a"` beside `prop "a.b"`) is
a contradiction no schema can express; the group wins, because a scalar `type`
left beside `properties` would reject every value rather than just that one.

Verified against a hand-converted slice of mise's registry — bools, uint with
`minimum`, `map<string, string>`, `option<bool>`, the `bool|string` union with
its four choices, three levels of `task.cache.remote_mode` — and diffed
against schema/mise.json for the same keys.

Six tests, each mutation-checked.
@jdx
jdx force-pushed the agent/config-schema branch from 34b6aeb to 0079a6d Compare August 13, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant