Skip to main content

agent_first_data/
cli_afdata.rs

1//! AFDATA adapter for the closed-world CLI core.
2//!
3//! [`crate::cli_spec`] decides whether an invocation is legal and what it
4//! resolved to. It deliberately knows nothing about how AFDATA expresses that
5//! decision. This module is the only place where the two meet: it turns a
6//! resolved outcome into a protocol event and owns AFDATA's `_secret` naming
7//! convention.
8
9use crate::cli_spec::{
10    ArgValueType, BuiltCliSpec, CliError, CliSpec, CliSpecError, ResolvedHelp, ResolvedVersion,
11};
12use crate::protocol::{Event, json_error, json_result};
13
14/// Build a registry under AFDATA's naming conventions.
15///
16/// `ArgSpec::sensitive` is not an independent switch. AFDATA already decides
17/// what a secret is by the `_secret` suffix, and the same convention drives
18/// config keys, log fields, and redaction. Deriving the bit from the argument
19/// id keeps one source of truth: an argument cannot be sensitive to the parser
20/// while staying invisible to redaction downstream. Marking a differently named
21/// argument sensitive is the reverse mistake, so it fails the build instead of
22/// silently diverging.
23pub fn build_afdata_cli(mut spec: CliSpec) -> Result<BuiltCliSpec, CliSpecError> {
24    for command in &mut spec.commands {
25        for argument in &mut command.arguments {
26            let suffixed = argument.argument_id.ends_with("_secret");
27            let is_flag = matches!(argument.value_type, ArgValueType::Flag);
28            if argument.sensitive && !suffixed {
29                return Err(CliSpecError {
30                    rule: "sensitive_without_secret_suffix",
31                    message: format!(
32                        "argument `{}` is marked sensitive; rename it to `{}_secret` so AFDATA \
33                         redaction covers it too",
34                        argument.argument_id, argument.argument_id
35                    ),
36                });
37            }
38            if argument.sensitive && is_flag {
39                return Err(CliSpecError {
40                    rule: "sensitive_flag",
41                    message: format!(
42                        "flag `{}` is marked sensitive, but a flag carries no value to redact",
43                        argument.argument_id
44                    ),
45                });
46            }
47            // A flag has no value, so `_secret` in its name says what the flag
48            // is *about* — `--reveal-secret` asks to reveal one, it does not
49            // carry one. Marking it would put the sensitive bit on something
50            // that structurally cannot leak, which reads as a real credential
51            // to anything downstream that trusts the bit.
52            argument.sensitive = suffixed && !is_flag;
53        }
54    }
55    spec.build()
56}
57
58/// Wrap a resolved help response in a `cli-help-v2` result event.
59pub fn cli_help_event(help: &ResolvedHelp) -> Event {
60    json_result(serde_json::json!({
61        "code": "help",
62        "help": help.model(),
63    }))
64    .build()
65}
66
67/// Wrap a resolved version response in a protocol result event.
68///
69/// This delegates to [`crate::build_cli_version`] rather than assembling its
70/// own payload. There is one version shape — `{code, name, version}` plus
71/// `display_name`/`build` when the registry carries them — and it is the same
72/// one the Go, Python, and TypeScript SDKs emit.
73pub fn cli_version_event(version: &ResolvedVersion) -> Event {
74    crate::cli::build_cli_version(
75        version.name(),
76        version.display_name(),
77        version.version(),
78        version.build(),
79    )
80}
81
82/// Static prose for the generated CLI reference.
83///
84/// Kept as Markdown files rather than escaped Rust string literals: these are
85/// paragraphs, and as `\`-continued literals they cannot be read as prose in a
86/// diff, need every backtick and quote escaped, and turn a wording change into
87/// an exercise in line continuations. The surrounding structure — headings,
88/// tables, per-command sections — stays in code, because that part is generated
89/// from the registry and is not prose.
90///
91/// This reference is emitted by every spore's `--docs`, not just afdata's, so
92/// the text is shared library data and belongs beside the generator.
93const SHAPES_PROSE: &str = include_str!("cli_reference/shapes.md");
94const CLI_ERRORS_PROSE: &str = include_str!("cli_reference/cli-errors.md");
95
96/// Render an offline Markdown reference for a whole registry.
97///
98/// help-v2 is deliberately lossy: it answers "how do I call this" one command
99/// at a time, in as few tokens as possible. A reference manual wants the
100/// opposite, so it is a separate capability rather than a format on the
101/// discovery path — the agent's `--help` never grows a documentation mode, and
102/// the manual can never disagree with the parser, because both are this
103/// registry. `--docs` is injected into every registry, so a tool exposes this
104/// without registering a command, and without spending a line of its
105/// subcommand listing on something no agent calls.
106///
107/// What is deliberately *not* repeated: a lone combination's description and
108/// id — the command heading already carries the description, and the id only
109/// matters for telling siblings apart — plus the shared output defaults and
110/// each command's arguments per combination. help repeats those because each help
111/// response is read alone; a document is read in order.
112pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
113    let spec = cli.spec();
114    let name = spec.name.as_str();
115    let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
116        .commands
117        .iter()
118        .filter(|command| !command.combinations.is_empty())
119        .collect();
120    commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
121
122    let path_of = |command: &crate::cli_spec::CommandSpec| {
123        if command.command_path.is_empty() {
124            name.to_string()
125        } else {
126            format!("{name} {}", command.command_path.join(" "))
127        }
128    };
129
130    let mut out = String::new();
131    out.push_str(&format!("# {name} CLI reference\n\n"));
132    out.push_str(&format!(
133        "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
134    ));
135    if let Some(about) = &spec.about {
136        out.push_str(&format!("{about}\n\n"));
137    }
138    out.push_str(&format!(
139        "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
140         typed invocation values, which parameter combinations are legal, output contracts, and \
141         help. An invocation runs only when it matches exactly one registered combination.\n\n"
142    ));
143
144    // Everything AFDATA registers on the caller's behalf, in one place. Split
145    // across sections it reads as unrelated trivia, and `--version`/`--docs`
146    // fall through the gap entirely — they belong to no command, so no command
147    // section would ever mention them.
148    let baseline = baseline_output(&commands);
149    out.push_str("## Global arguments\n\n");
150    // Not "no command declares them": `--version` and `--docs` are answered by
151    // the root alone, so a command may declare its own argument under that
152    // spelling — where one does, the entry below is still only the root's.
153    out.push_str(
154        "AFDATA registers these itself, so the syntax in [Commands](#commands) \
155         leaves them out.\n\n",
156    );
157    out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
158    out.push_str(
159        "| `--help` | every command | Every legal shape of that command, complete, plus its \
160         subcommands. JSON by default; `--output plain` for a terminal. |\n",
161    );
162    out.push_str(&format!(
163        "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
164         |\n"
165    ));
166    out.push_str(&format!(
167        "| `--docs` | {name} only | This document, rendered from the registry. |\n"
168    ));
169    if let Some(crate::cli_spec::OutputSpec::Protocol {
170        formats,
171        destinations,
172        default_format,
173        default_destination,
174        ..
175    }) = &baseline
176    {
177        out.push_str(&format!(
178            "| `--output <FORMAT>` | per output contract | Render as {} (default \
179             `{default_format}`). |\n",
180            formats.join(", ")
181        ));
182        out.push_str(&format!(
183            "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
184             to {} (default `{default_destination}`). |\n",
185            destinations.join(", ")
186        ));
187    }
188    out.push_str(
189        "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
190         stream to a file instead. |\n\n",
191    );
192    let baseline_line = baseline.as_ref().map(describe_output);
193    // The table above already lists the arguments and their values; the only
194    // thing left to say is what a successful call actually writes.
195    if baseline.is_some() {
196        out.push_str(
197            "Success output is protocol events, on those terms, unless a command's own \
198             **Output** line says otherwise.\n\n",
199        );
200    }
201    out.push_str(SHAPES_PROSE);
202    out.push('\n');
203
204    out.push_str("## Commands\n\n");
205    for command in &commands {
206        let path = path_of(command);
207        let anchor = path.replace(' ', "-");
208        let about = command.about.as_deref().unwrap_or("");
209        out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
210    }
211    out.push('\n');
212
213    for command in &commands {
214        let path = path_of(command);
215        out.push_str(&format!("### `{path}`\n\n"));
216        if let Some(about) = &command.about {
217            out.push_str(&format!("{about}\n\n"));
218        }
219
220        let Some(model) = cli.help(&command.command_path) else {
221            continue;
222        };
223        for shape in &model.shapes {
224            if model.shapes.len() > 1 {
225                let differs = shape.about.as_deref().unwrap_or_default();
226                out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
227            }
228            out.push_str(&format!(
229                "```\n{}\n```\n\n",
230                trim_output_arguments(&shape.usage)
231            ));
232        }
233
234        let combinations: Vec<&crate::cli_spec::Combination> =
235            command.combinations.iter().collect();
236        let contracts = output_contracts(&combinations);
237        let is_baseline =
238            matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
239        if !is_baseline {
240            out.push_str(&render_output(&contracts));
241        }
242
243        let documented: Vec<&crate::cli_spec::ArgSpec> = command
244            .arguments
245            .iter()
246            .filter(|argument| argument.about.is_some())
247            .collect();
248        if !documented.is_empty() {
249            if model.shapes.len() > 1 {
250                // One table per command, so it necessarily spans shapes that
251                // cannot be used together; the syntax above is what says which
252                // argument belongs where.
253                out.push_str("Arguments across every shape above:\n\n");
254            }
255            out.push_str("| Argument | Meaning |\n|---|---|\n");
256            for argument in documented {
257                let about = argument.about.as_deref().unwrap_or_default();
258                // The same spelling the usage line above uses, so the table can
259                // be read against it without translating ids back to flags.
260                out.push_str(&format!(
261                    "| `{}` | {about} |\n",
262                    crate::cli_spec::argument_key(argument)
263                ));
264            }
265            out.push('\n');
266        }
267    }
268
269    out.push_str("## Exit codes\n\n");
270    out.push_str(
271        "| Code | Meaning |\n|---|---|\n\
272         | 0 | The command ran and succeeded. |\n\
273         | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
274         | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
275         `cli_*` codes below. |\n",
276    );
277    // Sorted rather than left in declaration order: the table is read by code,
278    // and a registry author's ordering is not the reader's.
279    let mut declared_exit_codes: Vec<&crate::cli_spec::ExitCodeSpec> =
280        spec.exit_codes.iter().collect();
281    declared_exit_codes.sort_by_key(|exit| exit.code);
282    for exit in declared_exit_codes {
283        out.push_str(&format!("| {} | {} |\n", exit.code, exit.meaning));
284    }
285    out.push_str(
286        "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
287         retrying it unchanged cannot help, while exit 1 means it was.\n\n",
288    );
289
290    out.push_str("## CLI errors\n\n");
291    out.push_str(CLI_ERRORS_PROSE);
292    out
293}
294
295/// One line per command saying what its success output is, because the usage
296/// line can only show that by omission.
297fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
298    use crate::cli_spec::OutputSpec;
299    match output {
300        OutputSpec::Raw { file_sinks } => format!(
301            "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
302             strict JSON on stderr",
303            render_file_sinks(file_sinks)
304        ),
305        OutputSpec::Protocol {
306            formats,
307            destinations,
308            default_format,
309            default_destination,
310            file_sinks,
311            ..
312        } => format!(
313            "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
314             (default `{default_destination}`){}",
315            formats.join("/"),
316            destinations.join("/"),
317            render_file_sinks(file_sinks),
318        ),
319    }
320}
321
322/// Each distinct output contract a command exposes, in a stable order.
323fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
324    let mut lines: Vec<String> = Vec::new();
325    for combination in combinations {
326        let line = describe_output(&combination.output);
327        if !lines.contains(&line) {
328            lines.push(line);
329        }
330    }
331    lines
332}
333
334/// The output contract most commands share, if there is one worth hoisting.
335///
336/// Deterministic: ties break on the rendered description, never on registration
337/// order, so the same registry always renders the same document.
338fn baseline_output(
339    commands: &[&crate::cli_spec::CommandSpec],
340) -> Option<crate::cli_spec::OutputSpec> {
341    let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
342        std::collections::BTreeMap::new();
343    for command in commands {
344        let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
345        for combination in &command.combinations {
346            if !contracts.contains(&&combination.output) {
347                contracts.push(&combination.output);
348            }
349        }
350        if let [only] = contracts.as_slice() {
351            let entry = counts
352                .entry(describe_output(only))
353                .or_insert((0, (*only).clone()));
354            entry.0 += 1;
355        }
356    }
357    counts
358        .into_iter()
359        .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
360        .filter(|(_, (count, _))| *count > 1)
361        .map(|(_, (_, spec))| spec)
362}
363
364fn render_output(contracts: &[String]) -> String {
365    match contracts {
366        [] => String::new(),
367        [only] => format!("Output: {only}.\n\n"),
368        many => {
369            let mut out = String::from("Output differs by combination:\n\n");
370            for line in many {
371                out.push_str(&format!("- {line}\n"));
372            }
373            out.push('\n');
374            out
375        }
376    }
377}
378
379/// Drop the trailing AFDATA output arguments from a usage line.
380///
381/// The compiler always renders them last and in a fixed order, and their names
382/// are reserved, so no application argument can be mistaken for one. A document
383/// states the output contract once per command; only `--help`, whose response
384/// is read on its own, needs them inline.
385fn trim_output_arguments(usage: &str) -> &str {
386    let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
387        .iter()
388        .filter_map(|marker| usage.find(marker))
389        .min();
390    match cut {
391        Some(index) => usage[..index].trim_end(),
392        None => usage,
393    }
394}
395
396fn render_file_sinks(file_sinks: &[String]) -> String {
397    let mut names: Vec<&str> = Vec::new();
398    if file_sinks.iter().any(|sink| sink == "stdout") {
399        names.push("`--stdout-file`");
400    }
401    if file_sinks.iter().any(|sink| sink == "stderr") {
402        names.push("`--stderr-file`");
403    }
404    if names.is_empty() {
405        String::new()
406    } else {
407        format!("; redirect with {}", names.join(" or "))
408    }
409}
410
411/// Wrap a CLI-resolution failure in an event whose `code` names the failure.
412///
413/// The classification lives in `code`, the way `document_path_not_found` and
414/// its siblings already do — not in a second field beside a generic
415/// `cli_error`. One error taxonomy, one place to read it, and the skill's
416/// standing instruction ("branch on `error.code`") covers CLI errors too.
417///
418/// The event never carries raw argument values; `message` names the offending
419/// argument, and `hint` says what to run next.
420pub fn cli_error_event(error: &CliError) -> Event {
421    let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
422    match builder.build() {
423        Ok(event) => event,
424        Err(_) => json_error("cli_error", "failed to build CLI error")
425            .build()
426            .unwrap_or_else(|_| {
427                // All literals above are valid; this branch is unreachable
428                // but keeps production code panic-free.
429                json_result(serde_json::json!({"code":"internal_cli_error"})).build()
430            }),
431    }
432}
433
434/// The standard event for a program that misread its own resolved invocation.
435///
436/// Dispatch itself cannot fail — [`BoundCliSpec::resolve_from`] binds the
437/// handler while resolving, so there is no undispatchable invocation to report.
438/// What remains is a handler reading an argument the selected combination does
439/// not supply: an id it does not declare, or one whose type does not match the
440/// accessor. [`BoundCliSpec::call_every_combination`] catches the first from a
441/// test; this is for a program that also wants to say something at runtime.
442///
443/// It is a defect in the program, never in what the user typed, and the code
444/// and exit status have to say so. Reporting it as a usage error tells the
445/// caller to fix their command line and retry, and retrying cannot help — a
446/// mistake that has already been made in the wild, as
447/// `cli_invalid_argument_value` at exit 2. Emit this and exit 1; the
448/// application still owns the exit.
449pub fn cli_invocation_invalid_event(detail: &str) -> Event {
450    let builder = json_error("cli_invocation_invalid", detail)
451        .hint("this is a defect in the program, not in the command; report it");
452    match builder.build() {
453        Ok(event) => event,
454        Err(_) => json_error("cli_invocation_invalid", "invocation cannot be dispatched")
455            .build()
456            .unwrap_or_else(|_| {
457                // The literals above are valid, so this is unreachable; it
458                // keeps the path panic-free the way `cli_error_event` does.
459                json_result(serde_json::json!({"code":"internal_cli_error"})).build()
460            }),
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
468
469    fn output() -> OutputSpec {
470        OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
471    }
472
473    fn spec_with(argument: ArgSpec) -> CliSpec {
474        let id = argument.argument_id.clone();
475        CliSpec::new("demo", "1").command(
476            CommandSpec::root().arg(argument).combination(
477                Combination::new("only")
478                    .action("only")
479                    .required([id])
480                    .output(output()),
481            ),
482        )
483    }
484
485    #[test]
486    fn a_cli_declares_exit_codes_beyond_afdatas_own() {
487        let spec = CliSpec::new("demo", "1.0.0")
488            .lifecycle_output(output())
489            .exit_code(4, "The output could not be written.")
490            .exit_code(3, "The command ran and partly succeeded.")
491            .command(CommandSpec::root())
492            .build()
493            .unwrap();
494        let reference = render_cli_reference(&spec);
495        let table = reference
496            .split("## Exit codes")
497            .nth(1)
498            .expect("the reference documents exit codes");
499        // Sorted by code, not by declaration order: the table is read by code.
500        let partial = table.find("| 3 | The command ran and partly succeeded. |");
501        let write_failed = table.find("| 4 | The output could not be written. |");
502        assert!(partial.is_some() && write_failed.is_some(), "{table}");
503        assert!(partial < write_failed, "{table}");
504    }
505
506    #[test]
507    fn secret_suffix_drives_the_sensitive_bit() {
508        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
509        let argument = &built.spec().commands[0].arguments[0];
510        assert!(argument.sensitive);
511    }
512
513    #[test]
514    fn sensitive_without_the_suffix_fails_the_build() {
515        let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
516            .unwrap_err();
517        assert_eq!(error.rule, "sensitive_without_secret_suffix");
518    }
519
520    #[test]
521    fn a_plain_argument_stays_insensitive() {
522        let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
523        assert!(!built.spec().commands[0].arguments[0].sensitive);
524    }
525
526    // Locks the version payload shape. `--version` is a discovery entry point
527    // agents parse, and it lost `display_name`/`build` once before by going
528    // through a second, hand-rolled payload instead of `build_cli_version`.
529    #[test]
530    fn version_events_carry_the_full_documented_payload() {
531        let built = CliSpec::new("demo", "1.2.3")
532            .display_name("Demo Tool")
533            .build_id("abc1234")
534            .command(CommandSpec::root())
535            .build()
536            .unwrap();
537        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
538        else {
539            panic!("expected a version outcome");
540        };
541        assert_eq!(
542            cli_version_event(&version).as_value(),
543            &serde_json::json!({
544                "kind": "result",
545                "result": {
546                    "code": "version",
547                    "name": "demo",
548                    "display_name": "Demo Tool",
549                    "version": "1.2.3",
550                    "build": "abc1234",
551                },
552                "trace": {},
553            })
554        );
555    }
556
557    #[test]
558    fn version_events_omit_absent_metadata() {
559        let built = CliSpec::new("demo", "1.2.3")
560            .command(CommandSpec::root())
561            .build()
562            .unwrap();
563        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
564        else {
565            panic!("expected a version outcome");
566        };
567        let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
568        assert!(!payload.contains("display_name"), "{payload}");
569        assert!(!payload.contains("build"), "{payload}");
570    }
571
572    #[test]
573    fn cli_error_events_never_carry_a_secret_value() {
574        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
575        let error = built
576            .resolve_from([
577                "demo",
578                "--dsn-secret",
579                "postgres://user:password@example.test/db",
580                "--unknown",
581            ])
582            .unwrap_err();
583        let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
584        assert!(!serialized.contains("password"));
585        // The classification is the code, not a field beside it.
586        assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
587        // The command to run next reaches the caller through `hint`, which is
588        // the channel every error event already has.
589        assert!(serialized.contains("run `demo --help`"));
590    }
591
592    /// The point of shipping this helper is that every consumer reports the
593    /// same thing. Pin the code, so a consumer branching on it keeps working,
594    /// and pin that it is not a `cli_*` usage code — a caller must not be told
595    /// to fix their command line for a defect in the program.
596    #[test]
597    fn invocation_invalid_event_is_a_program_defect_not_a_usage_error() {
598        let event = cli_invocation_invalid_event("no handler for this registry's invocation");
599        let serialized = serde_json::to_string(event.as_value()).unwrap();
600
601        assert!(serialized.contains("\"code\":\"cli_invocation_invalid\""));
602        assert!(serialized.contains("no handler for this registry's invocation"));
603        // The hint has to say who should act. A usage hint here would send the
604        // caller to debug their own arguments for a dispatch-table bug.
605        assert!(serialized.contains("defect in the program"));
606        assert!(
607            crate::validate_protocol_event(event.as_value(), true).is_ok(),
608            "the helper must emit a strict event: {serialized}"
609        );
610    }
611
612    #[test]
613    fn a_secret_named_flag_is_not_marked_sensitive() {
614        // `--reveal-secret` asks to reveal a secret; it does not carry one, and
615        // a flag has no value that could leak. The suffix must not put the
616        // sensitive bit on it.
617        let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
618        let argument = built
619            .spec()
620            .commands
621            .iter()
622            .flat_map(|command| &command.arguments)
623            .find(|argument| argument.argument_id == "reveal_secret")
624            .expect("the flag is registered");
625        assert!(!argument.sensitive, "a flag has no value to redact");
626    }
627
628    #[test]
629    fn marking_a_flag_sensitive_is_a_contradiction() {
630        let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
631            .expect_err("a sensitive flag must not build");
632        assert_eq!(error.rule, "sensitive_flag");
633    }
634
635    #[test]
636    fn a_value_carrying_secret_argument_is_still_marked() {
637        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
638        let argument = built
639            .spec()
640            .commands
641            .iter()
642            .flat_map(|command| &command.arguments)
643            .find(|argument| argument.argument_id == "dsn_secret")
644            .expect("the option is registered");
645        assert!(argument.sensitive, "an option with a value still counts");
646    }
647}