Skip to main content

everruns_cli_contract/
lib.rs

1//! The `everruns` command-line contract: one grammar, shared by the CLI and
2//! the agent-facing command tree.
3//!
4//! A caller learns one CLI. What a person types in a terminal and what an
5//! agent types in its shell are the same words, with the same flags, the same
6//! short options, the same positionals and the same help. That only stays true
7//! if there is one definition, so this crate is it: the grammar as data, and
8//! the single function that turns it into a [`clap::Command`].
9//!
10//! It is part of the [Everruns](https://everruns.com) ecosystem and is consumed
11//! by `everruns-cli` and by the server's agent-facing command tree.
12//!
13//! # Example
14//!
15//! ```
16//! use everruns_cli_contract::{ArgKind, ContractArg, ContractCommand, ContractExample};
17//!
18//! let command = ContractCommand {
19//!     wire_name: "list_agents".into(),
20//!     path: vec!["agents".into()],
21//!     verb: "list".into(),
22//!     description: "List agents in the organization.".into(),
23//!     method: "GET".into(),
24//!     http_path: "/v1/agents".into(),
25//!     args: vec![ContractArg {
26//!         field: "limit".into(),
27//!         long: "limit".into(),
28//!         short: None,
29//!         position: None,
30//!         kind: ArgKind::Integer,
31//!         required: false,
32//!         help: Some("Maximum rows to return.".into()),
33//!         choices: vec![],
34//!     }],
35//!     examples: vec![ContractExample {
36//!         intent: "List the ten most recent agents".into(),
37//!         command: "everruns agents list --limit 10".into(),
38//!     }],
39//! };
40//!
41//! assert_eq!(command.spelling(), "agents list");
42//! assert!(command.after_help().contains("List the ten most recent agents:"));
43//! ```
44
45#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
46
47use clap::builder::{BoolishValueParser, PossibleValuesParser};
48use clap::{Arg, ArgAction, ColorChoice, Command};
49use serde::{Deserialize, Serialize};
50
51/// What one argument accepts.
52///
53/// A reduction of JSON Schema to the shapes a command line has: everything
54/// else is passed as one JSON document, because a shell argument is text and
55/// pretending otherwise only moves the parse somewhere less helpful.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ArgKind {
59    /// A switch. Accepts `--flag` and `--flag true`, because callers write
60    /// both and one of them erroring is a round trip spent on syntax.
61    Boolean,
62    Integer,
63    Number,
64    String,
65    /// Repeatable, and comma-splittable when the items are scalars.
66    StringList,
67    IntegerList,
68    /// An object or array of objects: one JSON document.
69    Json,
70}
71
72/// One argument of one command.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ContractArg {
75    /// Parameter name as the command deserializes it, e.g. `system_prompt`.
76    /// This is what dispatch sends, whatever the caller typed.
77    pub field: String,
78    /// Long spelling, without dashes, e.g. `system-prompt`.
79    pub long: String,
80    /// Short spelling, when the command declares one.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub short: Option<char>,
83    /// Position when this argument is also spelled as a bare word, 1-based.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub position: Option<usize>,
86    pub kind: ArgKind,
87    #[serde(default)]
88    pub required: bool,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub help: Option<String>,
91    /// Declared values, surfaced so help lists them and a wrong one is
92    /// corrected rather than dispatched.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub choices: Vec<String>,
95}
96
97/// One worked example, in the shape yolop's commands use: a line saying what
98/// the caller is trying to do, then the command that does it.
99///
100/// Both halves matter, and a bare command line is the half that gets written
101/// when the type does not ask for the other. `yolop sessions search --query X`
102/// tells a reader the syntax they could have guessed; "Search prior sessions
103/// for an exact marker" tells them when to reach for it. An agent reading
104/// `--help` is choosing between commands, not recalling one it already knows.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ContractExample {
107    /// What this invocation accomplishes, as a phrase. No trailing period:
108    /// the renderer adds the colon.
109    pub intent: String,
110    /// The complete, runnable command line.
111    pub command: String,
112}
113
114/// One command: where it sits, what it is called on the wire, how it is
115/// reached over HTTP, and what it accepts.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct ContractCommand {
118    /// Canonical identity, e.g. `list_agents`.
119    pub wire_name: String,
120    /// Noun path from the root, e.g. `["agents", "versions"]`.
121    pub path: Vec<String>,
122    /// Leaf verb, e.g. `list`.
123    pub verb: String,
124    pub description: String,
125    /// HTTP method and path template, so a consumer holding only an API client
126    /// can run the command without a hand-written implementation for it.
127    pub method: String,
128    pub http_path: String,
129    #[serde(default)]
130    pub args: Vec<ContractArg>,
131    /// Worked examples, rendered under the command's help.
132    ///
133    /// Not optional in practice: a guard asserts every routed command carries
134    /// at least one, because a command surface an agent cannot learn from
135    /// `--help` is one it will guess at instead.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub examples: Vec<ContractExample>,
138}
139
140impl ContractCommand {
141    /// The block rendered under a command's help: worked examples, then the
142    /// flat name the same command answers to.
143    pub fn after_help(&self) -> String {
144        let mut text = String::new();
145        if !self.examples.is_empty() {
146            text.push_str("Examples:\n");
147            for (index, example) in self.examples.iter().enumerate() {
148                if index > 0 {
149                    text.push('\n');
150                }
151                text.push_str(&format!("  {}:\n    {}\n", example.intent, example.command));
152            }
153            text.push('\n');
154        }
155        text.push_str(&format!("Wire name: {}", self.wire_name));
156        text
157    }
158
159    /// Space-joined spelling, e.g. `agents versions list`.
160    pub fn spelling(&self) -> String {
161        let mut parts = self.path.clone();
162        parts.push(self.verb.clone());
163        parts.join(" ")
164    }
165
166    /// The parser and the help for this command, as both consumers see it.
167    ///
168    /// `display_name` is the spelling the caller typed, so usage and errors
169    /// read back the grammar they used rather than a wire name they did not.
170    pub fn clap_command(&self, display_name: &str) -> Command {
171        // clap panics on a duplicate argument id, and an argument named `help`
172        // would collide with the flag clap adds for itself. Commands come from
173        // a catalog this code does not control, so the collision is resolved
174        // deliberately: the declared argument wins and that command has no
175        // `--help`. A panic inside an agent's shell is not an acceptable
176        // answer to an unusual field name.
177        let declares_help = self.args.iter().any(|arg| arg.long == "help");
178
179        let mut command = Command::new(display_name.to_string())
180            .about(self.description.clone())
181            .disable_version_flag(true)
182            .disable_help_flag(declares_help)
183            // The workspace links clap with its default features for the CLI,
184            // and cargo unifies that across the build, so colour is on unless
185            // a command says otherwise. Escape bytes are noise in a terminal
186            // and cost tokens in a tool result.
187            .color(ColorChoice::Never);
188
189        command = command.after_help(self.after_help());
190
191        for arg in &self.args {
192            command = command.arg(flag(arg));
193            if arg.position.is_some() {
194                command = command.arg(positional(arg));
195            }
196        }
197        command
198    }
199}
200
201/// Suffix that lets a bare-word spelling coexist with the same argument's
202/// long flag. Both reach one parameter, and giving both is a conflict clap
203/// reports itself rather than a silent winner.
204pub const POSITIONAL_SUFFIX: &str = "\u{1}positional";
205
206fn flag(arg: &ContractArg) -> Arg {
207    let mut built = Arg::new(arg.field.clone()).long(arg.long.clone());
208
209    // Parameters are named in snake_case because they are generated from Rust
210    // types, while the CLI spells them kebab. Both reach the same parameter,
211    // so a script written against either keeps working.
212    if arg.long != arg.field {
213        built = built.alias(arg.field.clone());
214    }
215    if let Some(short) = arg.short {
216        built = built.short(short);
217    }
218    if let Some(help) = &arg.help {
219        built = built.help(help.clone());
220    }
221    // An argument that also has a bare-word spelling cannot be required as a
222    // flag: the positional may be what supplies it.
223    built = built.required(arg.required && arg.position.is_none());
224    apply_kind(built, arg)
225}
226
227fn positional(arg: &ContractArg) -> Arg {
228    let built = Arg::new(format!("{}{POSITIONAL_SUFFIX}", arg.field))
229        .index(arg.position.unwrap_or(1))
230        .value_name(arg.long.to_uppercase().replace('-', "_"))
231        .conflicts_with(arg.field.clone())
232        .required(false)
233        .help(match &arg.help {
234            Some(help) => format!("{help} (may also be given as --{})", arg.long),
235            None => format!("{} (may also be given as --{})", arg.field, arg.long),
236        });
237    apply_kind(built, arg)
238}
239
240fn apply_kind(built: Arg, arg: &ContractArg) -> Arg {
241    match arg.kind {
242        ArgKind::Boolean => built
243            .num_args(0..=1)
244            .default_missing_value("true")
245            .value_parser(BoolishValueParser::new()),
246        ArgKind::Integer => built.value_parser(clap::value_parser!(i64)),
247        ArgKind::Number => built.value_parser(clap::value_parser!(f64)),
248        ArgKind::String if !arg.choices.is_empty() => {
249            built.value_parser(PossibleValuesParser::new(arg.choices.clone()))
250        }
251        ArgKind::String | ArgKind::Json => built,
252        // Comma-splitting is safe for scalars and wrong for documents, which
253        // may carry a comma of their own.
254        ArgKind::StringList | ArgKind::IntegerList => {
255            built.action(ArgAction::Append).value_delimiter(',')
256        }
257    }
258}
259
260pub mod declare;
261pub mod render;
262pub mod schema;
263
264pub use declare::{CliArg, CliExample, CliRoute};
265
266/// Read what clap parsed back out as the parameter object a command expects.
267///
268/// The contract knows what each argument was declared as, so this does not
269/// have to guess: an integer flag comes back a JSON number, a list comes back
270/// an array, and a document comes back parsed. Arguments the caller did not
271/// give are left out entirely, because defaults belong to the command and
272/// sending a parser's view of them would overwrite a real default with a guess.
273pub fn params_from(command: &ContractCommand, matches: &clap::ArgMatches) -> serde_json::Value {
274    let mut object = serde_json::Map::new();
275
276    for arg in &command.args {
277        // A bare-word spelling carries the same parameter under a distinct
278        // argument id; whichever the caller used, one value lands under the
279        // field's real name.
280        let ids = [
281            arg.field.clone(),
282            format!("{}{POSITIONAL_SUFFIX}", arg.field),
283        ];
284        for id in ids {
285            if let Some(value) = read(matches, &id, arg.kind) {
286                object.insert(arg.field.clone(), value);
287                break;
288            }
289        }
290    }
291
292    serde_json::Value::Object(object)
293}
294
295fn read(matches: &clap::ArgMatches, id: &str, kind: ArgKind) -> Option<serde_json::Value> {
296    use serde_json::Value;
297
298    // `try_get_one` rather than `get_one`: an id the command does not declare
299    // makes clap panic, and a positional id only exists when one was declared.
300    if !matches!(
301        matches.try_get_one::<String>(id).err(),
302        None | Some(clap::parser::MatchesError::Downcast { .. })
303    ) {
304        return None;
305    }
306    if !matches!(
307        matches.value_source(id),
308        Some(clap::parser::ValueSource::CommandLine)
309    ) {
310        return None;
311    }
312
313    match kind {
314        ArgKind::Boolean => matches.get_one::<bool>(id).copied().map(Value::Bool),
315        ArgKind::Integer => matches
316            .get_one::<i64>(id)
317            .copied()
318            .map(|value| Value::Number(value.into())),
319        ArgKind::Number => matches
320            .get_one::<f64>(id)
321            .copied()
322            .and_then(|value| serde_json::Number::from_f64(value).map(Value::Number)),
323        ArgKind::String => matches.get_one::<String>(id).cloned().map(Value::String),
324        ArgKind::Json => matches.get_one::<String>(id).map(|text| json_or_text(text)),
325        ArgKind::StringList => Some(Value::Array(
326            matches
327                .get_many::<String>(id)?
328                .cloned()
329                .map(Value::String)
330                .collect(),
331        )),
332        ArgKind::IntegerList => Some(Value::Array(
333            matches
334                .get_many::<String>(id)?
335                .map(|text| json_or_text(text))
336                .collect(),
337        )),
338    }
339}
340
341/// Parse a value that should be JSON, keeping the raw text when it is not.
342///
343/// Passing the text on is deliberate: the command validates against its own
344/// schema and will say what was wrong with it, which is a better error than one
345/// invented here with no knowledge of the target type.
346fn json_or_text(text: &str) -> serde_json::Value {
347    serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
348}
349
350/// Every command the control plane routes, as a checked-in artifact.
351///
352/// The contracts are derived from the command types in `everruns-server`, which
353/// the CLI cannot link: it would pull a database, a scheduler and a web server
354/// into a binary that talks HTTP. So generation happens where the commands are
355/// and the result travels as data, with a guard in the server asserting that
356/// this file still matches inventory.
357///
358/// Fetching the catalog at runtime instead would make `everruns --help` need a
359/// network round trip and a credential, which is the wrong trade for a CLI.
360pub fn commands() -> &'static [ContractCommand] {
361    static COMMANDS: std::sync::OnceLock<Vec<ContractCommand>> = std::sync::OnceLock::new();
362    COMMANDS.get_or_init(|| {
363        // The artifact is compiled in, so a parse failure is a broken build
364        // rather than a runtime condition: there is no catalog to fall back to
365        // and an empty one would silently serve a CLI with no commands. The
366        // parse error travels with the message, because "it is stale" alone
367        // does not say which field moved.
368        match serde_json::from_str(include_str!("../commands.json")) {
369            Ok(commands) => commands,
370            Err(error) => panic!(
371                "commands.json is generated and checked in; a parse failure means it is stale: {error}"
372            ),
373        }
374    })
375}
376
377#[cfg(test)]
378mod round_trip {
379    use super::*;
380    use declare::{CliArg, CliExample, CliRoute};
381    use serde_json::json;
382
383    const ROUTE: CliRoute = CliRoute::new(&["widgets"], "update")
384        .with_args(&[
385            CliArg::new("id").at(1),
386            CliArg::new("harness_name").short('H').long("harness"),
387            CliArg::new("tag").short('t'),
388        ])
389        .with_examples(&[CliExample::new(
390            "Rename a widget",
391            "everruns widgets update w_1 --name blue",
392        )]);
393
394    fn contract() -> ContractCommand {
395        schema::contract_for(
396            "update_widget",
397            "Update a widget.",
398            "PATCH",
399            "/v1/widgets/{id}",
400            &ROUTE,
401            &json!({
402                "type": "object",
403                "properties": {
404                    "id": { "type": "string" },
405                    "name": { "type": "string" },
406                    "harness_name": { "type": "string" },
407                    "tag": { "type": "array", "items": { "type": "string" } },
408                    "limit": { "type": "integer" },
409                    "archived": { "type": "boolean" },
410                    "metadata": { "type": "object" }
411                },
412                "required": ["id"]
413            }),
414        )
415    }
416
417    fn parse(line: &str) -> serde_json::Value {
418        let contract = contract();
419        let parser = contract.clap_command("everruns widgets update");
420        let argv = std::iter::once("everruns widgets update".to_string())
421            .chain(line.split_whitespace().map(ToOwned::to_owned));
422        let matches = parser
423            .try_get_matches_from(argv)
424            .unwrap_or_else(|error| panic!("{line}: {error}"));
425        params_from(&contract, &matches)
426    }
427
428    /// The two halves of the contract agree: what the parser accepts comes
429    /// back as the type the schema declared.
430    #[test]
431    fn values_come_back_as_the_types_the_schema_declared() {
432        let params = parse("w_1 --limit 5 --archived --tag a,b --metadata {\"k\":1}");
433        assert_eq!(params["id"], "w_1");
434        assert_eq!(params["limit"], 5);
435        assert_eq!(params["archived"], true);
436        assert_eq!(params["tag"], json!(["a", "b"]));
437        assert_eq!(params["metadata"], json!({ "k": 1 }));
438    }
439
440    /// A short option and a renamed long reach the parameter's real name, so
441    /// presentation never leaks into what the command receives.
442    #[test]
443    fn presentation_does_not_reach_the_command() {
444        for line in ["w_1 -H generic", "w_1 --harness generic"] {
445            assert_eq!(parse(line)["harness_name"], "generic", "{line}");
446        }
447    }
448
449    /// The parameter's own snake_case name keeps working, so a script written
450    /// against the flat command surface does not break.
451    #[test]
452    fn the_schemas_own_spelling_is_still_accepted() {
453        assert_eq!(
454            parse("w_1 --harness_name generic")["harness_name"],
455            "generic"
456        );
457    }
458
459    #[test]
460    fn a_bare_word_and_its_flag_reach_the_same_parameter() {
461        assert_eq!(parse("w_1")["id"], "w_1");
462        assert_eq!(parse("--id w_1")["id"], "w_1");
463    }
464
465    /// The schema decides, so nothing guesses: a parser sniffing at `1.20`
466    /// would make it 1.2 before the command ever saw it.
467    #[test]
468    fn a_version_like_value_stays_a_string() {
469        assert_eq!(parse("w_1 --name 1.20")["name"], "1.20");
470    }
471
472    #[test]
473    fn an_untouched_flag_is_not_sent() {
474        let params = parse("w_1");
475        assert_eq!(params, json!({ "id": "w_1" }));
476    }
477
478    /// Worked examples reach the help block in yolop's shape: the intent, then
479    /// the command line under it.
480    #[test]
481    fn help_carries_the_worked_examples_and_the_wire_name() {
482        let help = contract()
483            .clap_command("everruns widgets update")
484            .render_long_help()
485            .to_string();
486        assert!(help.contains("Rename a widget:"), "{help}");
487        assert!(
488            help.contains("everruns widgets update w_1 --name blue"),
489            "{help}"
490        );
491        assert!(help.contains("Wire name: update_widget"), "{help}");
492        assert!(!help.contains('\u{1b}'), "escape bytes in help: {help:?}");
493    }
494}