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 or auth changes. Schema semantics are well-tested but affect editor validation for adopters.

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

A new cli/src/schema module maps spec types, dotted keys, defaults, choices, and deprecation into schema shape; it omits scope="env" props, keeps hidden props, and uses unevaluatedProperties: false. The command errors when there are no file-relevant properties. command_effects treats the subcommand as read-only, write when --out-file is set.

Regenerated Fig, man, usage.kdl, and docs reference; unit and integration tests cover mapping edge cases and CLI behavior.

Reviewed by Cursor Bugbot for commit badb54a. 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: 6683f44d-b9d4-4464-89fc-c505e7d5c1cf

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 follow-up adds a post-generation empty-schema guard so specs containing no file-backed settings, including all-env-only configs, fail instead of emitting an unusable schema.

  • Checks the generated top-level properties map after env-only settings are filtered.
  • Adds integration coverage for empty, all-env-only, and file-backed configurations.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
cli/src/cli/generate/json_schema.rs The command now checks the generated properties map and correctly rejects schemas with no file-backed settings.
cli/src/schema/mod.rs Env-only properties are filtered before dotted-key nesting, so they cannot leave residual object nodes that bypass the guard.
cli/tests/json_schema.rs Integration coverage directly verifies that an all-env-only config fails while a config with one file-backed property succeeds.

Fix All in Greploop

Reviews (8): 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,857,235 → 174,986,488 +14.48% ⚠️ 14.64 → 16.15ms +10.34%
startup ▁▂▂▂▂▂▂▂▂▂▂█ 1,201,903 → 1,221,786 +1.65% ⚠️ 1.04 → 0.96ms -7.62%

2 benchmark(s) above the 1% gate: markdown +14.48%, startup +1.65%

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 40499 5960254 147x
usage: argv -> struct                            1747 ns      1.75 µs
clap: build tree + parse -> struct             505163 ns    505.16 µs
clap: parse -> struct, tree reused              24035 ns     24.03 µs
clap: build tree only                          314279 ns    314.28 µs

311e564a85a7 vs cca57930b536 · 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
`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 311e564 to badb54a Compare August 13, 2026 02:38

@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 badb54a. Configure here.

Comment thread cli/src/schema/mod.rs
object.remove("anyOf");
object.insert("enum".into(), allowed);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Choices skip nested container values

Medium Severity

choices are applied to only the first items or additionalProperties schema, so a nested container such as map<string, list<string>> or list<map<string, string>> gets enum on the inner array or object rather than on the scalar values. That enum is then AND-combined with type: array or type: object, which no value can satisfy, so an editor rejects every config that sets the property.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit badb54a. Configure here.

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