diff --git a/AGENTS.md b/AGENTS.md index 3838695..c974ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,16 @@ How this CLI behaves, so every command behaves the same way. Each rule names the Before committing, read the README sections your change touches and fix anything now untrue, including text written before your change. The README isn't a changelog: describe how the CLI works now, not what changed. `every_command_is_in_the_readme` in `src/main.rs` fails if a command is missing from it, but it can't tell whether the description is right. That part is on you. +## Help is the agents' documentation + +Agents learn the CLI from `--help`, which always matches the installed version, so it has to stand on its own: + +- **Every command and argument has a help line.** `every_command_and_argument_has_help` checks this. +- **Commands that change things show an example** in `after_help`, using `--json` and, where it applies, `-F -`. `help_examples_parse` checks that every example still parses. +- **Extra help text goes through `style::help`** (`style::examples` for examples), so its headings look like clap's. `help_headings_are_styled_like_clap_s` checks this. +- **`FOR_SCRIPTS` in `src/main.rs`** (the end of `source-coop --help`) sums up `--json`, prompts and exit codes. Keep it in step with the README and the JSON rules below. +- **No separate agent guide.** Help is the reference and the README is the guide. A skill or `llms.txt` should say when to use the CLI and point to `--help`, not copy flags. This file is for contributors: agents that only use the CLI never see it. + ## Commands - **One shape per resource.** A resource (`product`, and later accounts or members) has `list`, `view`, `create`, `edit` and `delete` under one noun. Name records as `ACCOUNT/PRODUCT`. diff --git a/Cargo.lock b/Cargo.lock index 342a1b3..37388d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1582,6 +1582,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "shlex", "tokio", "url", "wiremock", diff --git a/Cargo.toml b/Cargo.toml index 3d6af0b..7437318 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,4 +49,5 @@ inherits = "release" lto = "thin" [dev-dependencies] +shlex = "1.3.0" wiremock = "0.6.5" diff --git a/README.md b/README.md index 23f70f9..06e4059 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,8 @@ Exit codes are the same for every command: | 5 | Not found (HTTP 404) | | 130 | Interrupted (Ctrl+C) | +`source-coop --help` ends with a summary of this section, and each command's `--help` lists its flags with examples, so an AI agent can work out how to use the CLI from the CLI itself. + `creds` (`--format`), `auth token` (a bare token) and `api` (the API's response) already print output for machines, so they don't take `--json`. Neither does `auth login`, which signs in through the browser: `auth status --json` reports the result. ## Managing products diff --git a/src/main.rs b/src/main.rs index c98b699..6e5e995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,8 +57,28 @@ fn parse_duration(s: &str) -> Result { .ok_or_else(err) } +/// How to drive the CLI from a script or an AI agent, shown by `--help`. +const FOR_SCRIPTS: &str = "\ +For scripts and agents: + --json On reads and writes: one JSON document on stdout, notes on stderr. + Failures are JSON on stderr: {\"error\": {\"code\", \"message\", \"status\", \"field_errors\"}} + No prompts Without a terminal, or with SOURCE_PROMPT_DISABLED=1, nothing is asked; + give fields as flags or -F FILE (- for stdin), and --yes to delete. + SOURCE_TOKEN A bearer token for the API, used instead of the login session. + +Exit codes: + 0 success 1 other failure 2 bad usage 3 input rejected (fix field_errors) + 4 not signed in or not allowed 5 not found 130 interrupted + +Run `source-coop --help` for each command's flags and examples."; + #[derive(Parser)] -#[command(name = "source-coop", about = "Source Cooperative CLI", version)] +#[command( + name = "source-coop", + about = "Source Cooperative CLI", + version, + after_long_help = style::help(FOR_SCRIPTS) +)] struct Cli { /// Enable verbose output to see HTTP requests and responses #[arg(short, long, global = true)] @@ -1406,6 +1426,81 @@ mod tests { assert!(problems.is_empty(), "{problems:#?}"); } + /// `--help` is what agents read, so nothing in it may be blank. + #[test] + fn every_command_and_argument_has_help() { + fn check(cmd: &clap::Command, path: &str, missing: &mut Vec) { + if cmd.get_about().is_none() { + missing.push(path.to_string()); + } + for a in cmd.get_arguments() { + if a.get_help().is_none() && !["help", "version"].contains(&a.get_id().as_str()) { + missing.push(format!("{path} {}", a.get_id())); + } + } + for sub in cmd.get_subcommands().filter(|s| s.get_name() != "help") { + check(sub, &format!("{path} {}", sub.get_name()), missing); + } + } + let mut missing = vec![]; + check(&Cli::command(), "source-coop", &mut missing); + assert!(missing.is_empty(), "no help text: {missing:#?}"); + } + + /// Headings in `after_help` look like clap's own, which they only do + /// when written through `style::help`, so unstyled text is a mistake. + #[test] + fn help_headings_are_styled_like_clap_s() { + fn check(cmd: &clap::Command, path: &str, plain: &mut Vec) { + for text in [cmd.get_after_help(), cmd.get_after_long_help()] + .into_iter() + .flatten() + { + if text.ansi().to_string() == text.to_string() { + plain.push(path.to_string()); + } + } + for sub in cmd.get_subcommands() { + check(sub, &format!("{path} {}", sub.get_name()), plain); + } + } + let mut plain = vec![]; + check(&Cli::command(), "source-coop", &mut plain); + assert!( + plain.is_empty(), + "write these through style::help: {plain:#?}" + ); + } + + /// Agents copy examples as they are, so each must still parse: a renamed + /// flag fails here rather than in someone's script. + #[test] + fn help_examples_parse() { + fn check(cmd: &clap::Command, examples: &mut usize, broken: &mut Vec) { + let text = cmd + .get_after_help() + .map(|t| t.to_string()) + .unwrap_or_default(); + let lines = text.lines().skip_while(|l| *l != "Examples:").skip(1); + for line in lines.take_while(|l| l.starts_with(" ")) { + // The CLI's part of a pipeline, e.g. `echo '{…}' | source-coop …`. + let example = line.rsplit("| ").next().unwrap().trim(); + let args = shlex::split(example).unwrap_or_default(); + *examples += 1; + if let Err(e) = Cli::try_parse_from(&args) { + broken.push(format!("{example}\n{e}")); + } + } + for sub in cmd.get_subcommands() { + check(sub, examples, broken); + } + } + let (mut examples, mut broken) = (0, vec![]); + check(&Cli::command(), &mut examples, &mut broken); + assert!(examples > 0, "found no examples to check"); + assert!(broken.is_empty(), "{}", broken.join("\n\n")); + } + #[test] fn every_command_is_in_the_readme() { let readme = include_str!("../README.md"); diff --git a/src/product.rs b/src/product.rs index 3b1fcdf..febeb0f 100644 --- a/src/product.rs +++ b/src/product.rs @@ -12,14 +12,31 @@ use std::io::{IsTerminal, Write}; #[derive(Subcommand)] pub enum ProductCommand { /// List public products, or one account's products + #[command( + after_help = crate::style::examples(&[ + "source-coop product list my-org --json", + "source-coop product list -q climate --format table", + ]) + )] List(ListArgs), /// Show one product + #[command(after_help = crate::style::examples(&["source-coop product view my-org/my-data --json"]))] View(ViewArgs), /// Create a product + #[command( + after_help = crate::style::examples(&[ + "source-coop product create my-org/my-data -t \"My data\" --visibility public --json", + "echo '{\"title\": \"My data\"}' | source-coop product create my-org/my-data -F - --json", + ]) + )] Create(CreateArgs), /// Change a product's title, description, visibility or state + #[command( + after_help = crate::style::examples(&["source-coop product edit my-org/my-data -d \"New description\" --json"]) + )] Edit(EditArgs), /// Delete a product and, unless --preserve-data, its data + #[command(after_help = crate::style::examples(&["source-coop product delete my-org/my-data --yes --json"]))] Delete(DeleteArgs), } diff --git a/src/style.rs b/src/style.rs index a867740..525cb98 100644 --- a/src/style.rs +++ b/src/style.rs @@ -27,6 +27,30 @@ pub fn id(on: bool, s: &str) -> String { paint(on, "90", s) } +/// Help text with its section headings (unindented lines ending in `:`) +/// styled like clap's own "Usage:" and "Options:". clap drops the styling +/// wherever it prints help without color. +pub fn help(text: &str) -> clap::builder::StyledStr { + let h = clap::builder::Styles::default().get_header().to_owned(); + let lines: Vec<_> = text + .lines() + .map(|l| { + if l.starts_with(' ') || !l.ends_with(':') { + l.to_string() + } else { + format!("{h}{l}{h:#}") + } + }) + .collect(); + lines.join("\n").into() +} + +/// A command's "Examples:" help section, a command a line. +pub fn examples(commands: &[&str]) -> clap::builder::StyledStr { + let lines: Vec<_> = commands.iter().map(|c| format!(" {c}")).collect(); + help(&format!("Examples:\n{}", lines.join("\n"))) +} + /// How long ago `then` was, at `now`, in the largest whole unit: "just now", /// "5 minutes ago", "3 days ago", "2 years ago". pub fn ago(then: chrono::DateTime, now: chrono::DateTime) -> String {