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}