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/// Render an offline Markdown reference for a whole registry.
83///
84/// help-v2 is deliberately lossy: it answers "how do I call this" one command
85/// at a time, in as few tokens as possible. A reference manual wants the
86/// opposite, so it is a separate capability rather than a format on the
87/// discovery path — the agent's `--help` never grows a documentation mode, and
88/// the manual can never disagree with the parser, because both are this
89/// registry. `--docs` is injected into every registry, so a tool exposes this
90/// without registering a command, and without spending a line of its
91/// subcommand listing on something no agent calls.
92///
93/// What is deliberately *not* repeated: a lone combination's description and
94/// id — the command heading already carries the description, and the id only
95/// matters for telling siblings apart — plus the shared output defaults and
96/// each command's arguments per combination. help repeats those because each help
97/// response is read alone; a document is read in order.
98pub fn render_cli_reference(cli: &BuiltCliSpec) -> String {
99    let spec = cli.spec();
100    let name = spec.name.as_str();
101    let mut commands: Vec<&crate::cli_spec::CommandSpec> = spec
102        .commands
103        .iter()
104        .filter(|command| !command.combinations.is_empty())
105        .collect();
106    commands.sort_by(|left, right| left.command_path.cmp(&right.command_path));
107
108    let path_of = |command: &crate::cli_spec::CommandSpec| {
109        if command.command_path.is_empty() {
110            name.to_string()
111        } else {
112            format!("{name} {}", command.command_path.join(" "))
113        }
114    };
115
116    let mut out = String::new();
117    out.push_str(&format!("# {name} CLI reference\n\n"));
118    out.push_str(&format!(
119        "<!-- Generated by `{name} --docs`. Do not edit by hand. -->\n\n"
120    ));
121    if let Some(about) = &spec.about {
122        out.push_str(&format!("{about}\n\n"));
123    }
124    out.push_str(&format!(
125        "`{name}` is compiled from a closed `cli-spec-v1` registry: one source for argv parsing, \
126         typed invocation values, which parameter combinations are legal, output contracts, and \
127         help. An invocation runs only when it matches exactly one registered combination.\n\n"
128    ));
129
130    // Everything AFDATA registers on the caller's behalf, in one place. Split
131    // across sections it reads as unrelated trivia, and `--version`/`--docs`
132    // fall through the gap entirely — they belong to no command, so no command
133    // section would ever mention them.
134    let baseline = baseline_output(&commands);
135    out.push_str("## Global arguments\n\n");
136    // Not "no command declares them": `--version` and `--docs` are answered by
137    // the root alone, so a command may declare its own argument under that
138    // spelling — where one does, the entry below is still only the root's.
139    out.push_str(
140        "AFDATA registers these itself, so the syntax in [Commands](#commands) \
141         leaves them out.\n\n",
142    );
143    out.push_str("| Argument | Where | What it does |\n|---|---|---|\n");
144    out.push_str(
145        "| `--help` | every command | Every legal shape of that command, complete, plus its \
146         subcommands. JSON by default; `--output plain` for a terminal. |\n",
147    );
148    out.push_str(&format!(
149        "| `--version` | {name} only | Name, version, and build identity as one protocol result. \
150         |\n"
151    ));
152    out.push_str(&format!(
153        "| `--docs` | {name} only | This document, rendered from the registry. |\n"
154    ));
155    if let Some(crate::cli_spec::OutputSpec::Protocol {
156        formats,
157        destinations,
158        default_format,
159        default_destination,
160        ..
161    }) = &baseline
162    {
163        out.push_str(&format!(
164            "| `--output <FORMAT>` | per output contract | Render as {} (default \
165             `{default_format}`). |\n",
166            formats.join(", ")
167        ));
168        out.push_str(&format!(
169            "| `--output-to <DESTINATION>` | per output contract | Route results and diagnostics \
170             to {} (default `{default_destination}`). |\n",
171            destinations.join(", ")
172        ));
173    }
174    out.push_str(
175        "| `--stdout-file <PATH>`, `--stderr-file <PATH>` | per output contract | Append that \
176         stream to a file instead. |\n\n",
177    );
178    let baseline_line = baseline.as_ref().map(describe_output);
179    // The table above already lists the arguments and their values; the only
180    // thing left to say is what a successful call actually writes.
181    if baseline.is_some() {
182        out.push_str(
183            "Success output is protocol events, on those terms, unless a command's own \
184             **Output** line says otherwise.\n\n",
185        );
186    }
187    out.push_str(
188        "A **shape** is one legal set of arguments that may appear together, under a stable id. \
189         Where a command has more than one, each id is a heading below. `--help` returns them \
190         all at once, so discovering a command costs one call; there is no recursive mode across \
191         commands, and this document is that view.\n\n",
192    );
193
194    out.push_str("## Commands\n\n");
195    for command in &commands {
196        let path = path_of(command);
197        let anchor = path.replace(' ', "-");
198        let about = command.about.as_deref().unwrap_or("");
199        out.push_str(&format!("- [`{path}`](#{anchor}) — {about}\n"));
200    }
201    out.push('\n');
202
203    for command in &commands {
204        let path = path_of(command);
205        out.push_str(&format!("### `{path}`\n\n"));
206        if let Some(about) = &command.about {
207            out.push_str(&format!("{about}\n\n"));
208        }
209
210        let Some(model) = cli.help(&command.command_path) else {
211            continue;
212        };
213        for shape in &model.shapes {
214            if model.shapes.len() > 1 {
215                let differs = shape.about.as_deref().unwrap_or_default();
216                out.push_str(&format!("#### `{}` — {differs}\n\n", shape.id));
217            }
218            out.push_str(&format!(
219                "```\n{}\n```\n\n",
220                trim_output_arguments(&shape.usage)
221            ));
222        }
223
224        let combinations: Vec<&crate::cli_spec::Combination> =
225            command.combinations.iter().collect();
226        let contracts = output_contracts(&combinations);
227        let is_baseline =
228            matches!((contracts.as_slice(), &baseline_line), ([only], Some(line)) if only == line);
229        if !is_baseline {
230            out.push_str(&render_output(&contracts));
231        }
232
233        let documented: Vec<&crate::cli_spec::ArgSpec> = command
234            .arguments
235            .iter()
236            .filter(|argument| argument.about.is_some())
237            .collect();
238        if !documented.is_empty() {
239            if model.shapes.len() > 1 {
240                // One table per command, so it necessarily spans shapes that
241                // cannot be used together; the syntax above is what says which
242                // argument belongs where.
243                out.push_str("Arguments across every shape above:\n\n");
244            }
245            out.push_str("| Argument | Meaning |\n|---|---|\n");
246            for argument in documented {
247                let about = argument.about.as_deref().unwrap_or_default();
248                // The same spelling the usage line above uses, so the table can
249                // be read against it without translating ids back to flags.
250                out.push_str(&format!(
251                    "| `{}` | {about} |\n",
252                    crate::cli_spec::argument_key(argument)
253                ));
254            }
255            out.push('\n');
256        }
257    }
258
259    out.push_str("## Exit codes\n\n");
260    out.push_str(
261        "| Code | Meaning |\n|---|---|\n\
262         | 0 | The command ran and succeeded. |\n\
263         | 1 | The command ran and failed. The event carries a domain `error.code`. |\n\
264         | 2 | The invocation was rejected before anything ran. `error.code` is one of the \
265         `cli_*` codes below. |\n",
266    );
267    // Sorted rather than left in declaration order: the table is read by code,
268    // and a registry author's ordering is not the reader's.
269    let mut declared_exit_codes: Vec<&crate::cli_spec::ExitCodeSpec> =
270        spec.exit_codes.iter().collect();
271    declared_exit_codes.sort_by_key(|exit| exit.code);
272    for exit in declared_exit_codes {
273        out.push_str(&format!("| {} | {} |\n", exit.code, exit.meaning));
274    }
275    out.push_str(
276        "\nThe split is the useful one for a caller: exit 2 means the call was never made, so \
277         retrying it unchanged cannot help, while exit 1 means it was.\n\n",
278    );
279
280    out.push_str("## CLI errors\n\n");
281    out.push_str(
282        "Every structural failure emits one strict JSON `kind:\"error\"` event on stderr, leaves \
283         stdout empty, and exits 2. The `code` names the failure — `cli_unknown_argument` for an \
284         unknown spelling, `cli_unregistered_combination` for registered arguments in a mixture \
285         that is not, and one each for `cli_unknown_command`, `cli_missing_argument_value`, \
286         `cli_invalid_argument_value`, `cli_duplicate_argument`, `cli_unexpected_positional`, and \
287         `cli_invalid_utf8`. `message` names the offending argument and `hint` gives the command \
288         to run next; neither ever quotes a raw value, including secrets. These are decided \
289         before any config, secret source, filesystem, network, or domain I/O.\n\n\
290         Domain failures (exit 1) carry their own stable `error.code` instead, drawn from \
291         whatever this tool defines rather than from the `cli_*` set. No error message quotes a \
292         raw value it was given — an error event is routinely logged, and the input may hold \
293         secrets.\n",
294    );
295    out
296}
297
298/// One line per command saying what its success output is, because the usage
299/// line can only show that by omission.
300fn describe_output(output: &crate::cli_spec::OutputSpec) -> String {
301    use crate::cli_spec::OutputSpec;
302    match output {
303        OutputSpec::Raw { file_sinks } => format!(
304            "raw bytes on success; rejects `--output` and `--output-to`{}. Failures are still \
305             strict JSON on stderr",
306            render_file_sinks(file_sinks)
307        ),
308        OutputSpec::Protocol {
309            formats,
310            destinations,
311            default_format,
312            default_destination,
313            file_sinks,
314            ..
315        } => format!(
316            "protocol events; `--output` {} (default `{default_format}`), `--output-to` {} \
317             (default `{default_destination}`){}",
318            formats.join("/"),
319            destinations.join("/"),
320            render_file_sinks(file_sinks),
321        ),
322    }
323}
324
325/// Each distinct output contract a command exposes, in a stable order.
326fn output_contracts(combinations: &[&crate::cli_spec::Combination]) -> Vec<String> {
327    let mut lines: Vec<String> = Vec::new();
328    for combination in combinations {
329        let line = describe_output(&combination.output);
330        if !lines.contains(&line) {
331            lines.push(line);
332        }
333    }
334    lines
335}
336
337/// The output contract most commands share, if there is one worth hoisting.
338///
339/// Deterministic: ties break on the rendered description, never on registration
340/// order, so the same registry always renders the same document.
341fn baseline_output(
342    commands: &[&crate::cli_spec::CommandSpec],
343) -> Option<crate::cli_spec::OutputSpec> {
344    let mut counts: std::collections::BTreeMap<String, (usize, crate::cli_spec::OutputSpec)> =
345        std::collections::BTreeMap::new();
346    for command in commands {
347        let mut contracts: Vec<&crate::cli_spec::OutputSpec> = Vec::new();
348        for combination in &command.combinations {
349            if !contracts.contains(&&combination.output) {
350                contracts.push(&combination.output);
351            }
352        }
353        if let [only] = contracts.as_slice() {
354            let entry = counts
355                .entry(describe_output(only))
356                .or_insert((0, (*only).clone()));
357            entry.0 += 1;
358        }
359    }
360    counts
361        .into_iter()
362        .max_by(|left, right| left.1.0.cmp(&right.1.0).then_with(|| right.0.cmp(&left.0)))
363        .filter(|(_, (count, _))| *count > 1)
364        .map(|(_, (_, spec))| spec)
365}
366
367fn render_output(contracts: &[String]) -> String {
368    match contracts {
369        [] => String::new(),
370        [only] => format!("Output: {only}.\n\n"),
371        many => {
372            let mut out = String::from("Output differs by combination:\n\n");
373            for line in many {
374                out.push_str(&format!("- {line}\n"));
375            }
376            out.push('\n');
377            out
378        }
379    }
380}
381
382/// Drop the trailing AFDATA output arguments from a usage line.
383///
384/// The compiler always renders them last and in a fixed order, and their names
385/// are reserved, so no application argument can be mistaken for one. A document
386/// states the output contract once per command; only `--help`, whose response
387/// is read on its own, needs them inline.
388fn trim_output_arguments(usage: &str) -> &str {
389    let cut = ["[--output ", "[--stdout-file ", "[--stderr-file "]
390        .iter()
391        .filter_map(|marker| usage.find(marker))
392        .min();
393    match cut {
394        Some(index) => usage[..index].trim_end(),
395        None => usage,
396    }
397}
398
399fn render_file_sinks(file_sinks: &[String]) -> String {
400    let mut names: Vec<&str> = Vec::new();
401    if file_sinks.iter().any(|sink| sink == "stdout") {
402        names.push("`--stdout-file`");
403    }
404    if file_sinks.iter().any(|sink| sink == "stderr") {
405        names.push("`--stderr-file`");
406    }
407    if names.is_empty() {
408        String::new()
409    } else {
410        format!("; redirect with {}", names.join(" or "))
411    }
412}
413
414/// Wrap a CLI-resolution failure in an event whose `code` names the failure.
415///
416/// The classification lives in `code`, the way `document_path_not_found` and
417/// its siblings already do — not in a second field beside a generic
418/// `cli_error`. One error taxonomy, one place to read it, and the skill's
419/// standing instruction ("branch on `error.code`") covers CLI errors too.
420///
421/// The event never carries raw argument values; `message` names the offending
422/// argument, and `hint` says what to run next.
423pub fn cli_error_event(error: &CliError) -> Event {
424    let builder = json_error(error.rule.code(), &error.message).hint(&error.hint);
425    match builder.build() {
426        Ok(event) => event,
427        Err(_) => json_error("cli_error", "failed to build CLI error")
428            .build()
429            .unwrap_or_else(|_| {
430                // All literals above are valid; this branch is unreachable
431                // but keeps production code panic-free.
432                json_result(serde_json::json!({"code":"internal_cli_error"})).build()
433            }),
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::cli_spec::{ArgSpec, CliOutcome, Combination, CommandSpec, OutputSpec};
441
442    fn output() -> OutputSpec {
443        OutputSpec::protocol_finite(["json"], ["split"], "json", "split")
444    }
445
446    fn spec_with(argument: ArgSpec) -> CliSpec {
447        let id = argument.argument_id.clone();
448        CliSpec::new("demo", "1").command(
449            CommandSpec::root().arg(argument).combination(
450                Combination::new("only")
451                    .action("only")
452                    .required([id])
453                    .output(output()),
454            ),
455        )
456    }
457
458    #[test]
459    fn a_cli_declares_exit_codes_beyond_afdatas_own() {
460        let spec = CliSpec::new("demo", "1.0.0")
461            .lifecycle_output(output())
462            .exit_code(4, "The output could not be written.")
463            .exit_code(3, "The command ran and partly succeeded.")
464            .command(CommandSpec::root())
465            .build()
466            .unwrap();
467        let reference = render_cli_reference(&spec);
468        let table = reference
469            .split("## Exit codes")
470            .nth(1)
471            .expect("the reference documents exit codes");
472        // Sorted by code, not by declaration order: the table is read by code.
473        let partial = table.find("| 3 | The command ran and partly succeeded. |");
474        let write_failed = table.find("| 4 | The output could not be written. |");
475        assert!(partial.is_some() && write_failed.is_some(), "{table}");
476        assert!(partial < write_failed, "{table}");
477    }
478
479    #[test]
480    fn secret_suffix_drives_the_sensitive_bit() {
481        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
482        let argument = &built.spec().commands[0].arguments[0];
483        assert!(argument.sensitive);
484    }
485
486    #[test]
487    fn sensitive_without_the_suffix_fails_the_build() {
488        let error = build_afdata_cli(spec_with(ArgSpec::option("--token", "TOKEN").sensitive()))
489            .unwrap_err();
490        assert_eq!(error.rule, "sensitive_without_secret_suffix");
491    }
492
493    #[test]
494    fn a_plain_argument_stays_insensitive() {
495        let built = build_afdata_cli(spec_with(ArgSpec::option("--host", "HOST"))).unwrap();
496        assert!(!built.spec().commands[0].arguments[0].sensitive);
497    }
498
499    // Locks the version payload shape. `--version` is a discovery entry point
500    // agents parse, and it lost `display_name`/`build` once before by going
501    // through a second, hand-rolled payload instead of `build_cli_version`.
502    #[test]
503    fn version_events_carry_the_full_documented_payload() {
504        let built = CliSpec::new("demo", "1.2.3")
505            .display_name("Demo Tool")
506            .build_id("abc1234")
507            .command(CommandSpec::root())
508            .build()
509            .unwrap();
510        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
511        else {
512            panic!("expected a version outcome");
513        };
514        assert_eq!(
515            cli_version_event(&version).as_value(),
516            &serde_json::json!({
517                "kind": "result",
518                "result": {
519                    "code": "version",
520                    "name": "demo",
521                    "display_name": "Demo Tool",
522                    "version": "1.2.3",
523                    "build": "abc1234",
524                },
525                "trace": {},
526            })
527        );
528    }
529
530    #[test]
531    fn version_events_omit_absent_metadata() {
532        let built = CliSpec::new("demo", "1.2.3")
533            .command(CommandSpec::root())
534            .build()
535            .unwrap();
536        let CliOutcome::Version(version) = built.resolve_from(["demo", "--version"]).unwrap()
537        else {
538            panic!("expected a version outcome");
539        };
540        let payload = serde_json::to_string(cli_version_event(&version).as_value()).unwrap();
541        assert!(!payload.contains("display_name"), "{payload}");
542        assert!(!payload.contains("build"), "{payload}");
543    }
544
545    #[test]
546    fn cli_error_events_never_carry_a_secret_value() {
547        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
548        let error = built
549            .resolve_from([
550                "demo",
551                "--dsn-secret",
552                "postgres://user:password@example.test/db",
553                "--unknown",
554            ])
555            .unwrap_err();
556        let serialized = serde_json::to_string(cli_error_event(&error).as_value()).unwrap();
557        assert!(!serialized.contains("password"));
558        // The classification is the code, not a field beside it.
559        assert!(serialized.contains("\"code\":\"cli_unknown_argument\""));
560        // The command to run next reaches the caller through `hint`, which is
561        // the channel every error event already has.
562        assert!(serialized.contains("run `demo --help`"));
563    }
564
565    #[test]
566    fn a_secret_named_flag_is_not_marked_sensitive() {
567        // `--reveal-secret` asks to reveal a secret; it does not carry one, and
568        // a flag has no value that could leak. The suffix must not put the
569        // sensitive bit on it.
570        let built = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret"))).unwrap();
571        let argument = built
572            .spec()
573            .commands
574            .iter()
575            .flat_map(|command| &command.arguments)
576            .find(|argument| argument.argument_id == "reveal_secret")
577            .expect("the flag is registered");
578        assert!(!argument.sensitive, "a flag has no value to redact");
579    }
580
581    #[test]
582    fn marking_a_flag_sensitive_is_a_contradiction() {
583        let error = build_afdata_cli(spec_with(ArgSpec::flag("--reveal-secret").sensitive()))
584            .expect_err("a sensitive flag must not build");
585        assert_eq!(error.rule, "sensitive_flag");
586    }
587
588    #[test]
589    fn a_value_carrying_secret_argument_is_still_marked() {
590        let built = build_afdata_cli(spec_with(ArgSpec::option("--dsn-secret", "DSN"))).unwrap();
591        let argument = built
592            .spec()
593            .commands
594            .iter()
595            .flat_map(|command| &command.arguments)
596            .find(|argument| argument.argument_id == "dsn_secret")
597            .expect("the option is registered");
598        assert!(argument.sensitive, "an option with a value still counts");
599    }
600}