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