From 134dbe9da82ca580c9191c6c87ff741cd28a1759 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 21:23:52 -0700 Subject: [PATCH 1/6] docs: --help documents the CLI for agents Every command and argument has a help line (checked by a test), product commands show examples, and `source-coop --help` ends with a summary for scripts and agents: --json, prompts, exit codes. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +++++++++ README.md | 2 ++ src/main.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- src/product.rs | 11 +++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3838695..6cc5744 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,15 @@ 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 -`. +- **`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/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..16ba711 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 = FOR_SCRIPTS +)] struct Cli { /// Enable verbose output to see HTTP requests and responses #[arg(short, long, global = true)] @@ -1406,6 +1426,27 @@ 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:#?}"); + } + #[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..97de14e 100644 --- a/src/product.rs +++ b/src/product.rs @@ -12,14 +12,25 @@ use std::io::{IsTerminal, Write}; #[derive(Subcommand)] pub enum ProductCommand { /// List public products, or one account's products + #[command( + after_help = "Examples:\n source-coop product list my-org --json\n source-coop product list -q climate --format table" + )] List(ListArgs), /// Show one product + #[command(after_help = "Examples:\n source-coop product view my-org/my-data --json")] View(ViewArgs), /// Create a product + #[command( + after_help = "Examples:\n source-coop product create my-org/my-data -t \"My data\" --visibility public --json\n 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 = "Examples:\n 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 = "Examples:\n source-coop product delete my-org/my-data --yes --json")] Delete(DeleteArgs), } From c4da7d5c1a9ba5f75434b2bcdbbf29fbf9c94499 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 21:55:38 -0700 Subject: [PATCH 2/6] fix: style help's own headings like clap's Co-Authored-By: Claude Opus 5.5 --- src/main.rs | 2 +- src/product.rs | 10 +++++----- src/style.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 16ba711..fa95793 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,7 +77,7 @@ Run `source-coop --help` for each command's flags and examples."; name = "source-coop", about = "Source Cooperative CLI", version, - after_long_help = FOR_SCRIPTS + after_long_help = style::help(FOR_SCRIPTS) )] struct Cli { /// Enable verbose output to see HTTP requests and responses diff --git a/src/product.rs b/src/product.rs index 97de14e..dd874da 100644 --- a/src/product.rs +++ b/src/product.rs @@ -13,24 +13,24 @@ use std::io::{IsTerminal, Write}; pub enum ProductCommand { /// List public products, or one account's products #[command( - after_help = "Examples:\n source-coop product list my-org --json\n source-coop product list -q climate --format table" + after_help = crate::style::help("Examples:\n source-coop product list my-org --json\n source-coop product list -q climate --format table") )] List(ListArgs), /// Show one product - #[command(after_help = "Examples:\n source-coop product view my-org/my-data --json")] + #[command(after_help = crate::style::help("Examples:\n source-coop product view my-org/my-data --json"))] View(ViewArgs), /// Create a product #[command( - after_help = "Examples:\n source-coop product create my-org/my-data -t \"My data\" --visibility public --json\n echo '{\"title\": \"My data\"}' | source-coop product create my-org/my-data -F - --json" + after_help = crate::style::help("Examples:\n source-coop product create my-org/my-data -t \"My data\" --visibility public --json\n 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 = "Examples:\n source-coop product edit my-org/my-data -d \"New description\" --json" + after_help = crate::style::help("Examples:\n 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 = "Examples:\n source-coop product delete my-org/my-data --yes --json")] + #[command(after_help = crate::style::help("Examples:\n source-coop product delete my-org/my-data --yes --json"))] Delete(DeleteArgs), } diff --git a/src/style.rs b/src/style.rs index a867740..80da4b5 100644 --- a/src/style.rs +++ b/src/style.rs @@ -27,6 +27,24 @@ 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() +} + /// 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 { From ef97257a59506d4b85508cc9ae03823d4dfa8507 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 21:57:47 -0700 Subject: [PATCH 3/6] test: help headings must go through style::help Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + src/main.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6cc5744..78f4d9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Agents learn the CLI from `--help`, which always matches the installed version, - **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 -`. +- **Extra help text goes through `style::help`**, 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. diff --git a/src/main.rs b/src/main.rs index fa95793..029df8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1447,6 +1447,36 @@ mod tests { 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`. + #[test] + fn help_headings_are_styled_like_clap_s() { + fn check(cmd: &clap::Command, path: &str, plain: &mut Vec) { + let h = clap::builder::Styles::default().get_header().to_owned(); + for text in [cmd.get_after_help(), cmd.get_after_long_help()] + .into_iter() + .flatten() + { + let ansi = text.ansi().to_string(); + for l in text.to_string().lines() { + let heading = !l.starts_with(' ') && l.ends_with(':'); + if heading && !ansi.contains(&format!("{h}{l}{h:#}")) { + plain.push(format!("{path}: {l}")); + } + } + } + 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:#?}" + ); + } + #[test] fn every_command_is_in_the_readme() { let readme = include_str!("../README.md"); From 7a2c93e110e76c6b750563dc665320358a07ca42 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 21:59:28 -0700 Subject: [PATCH 4/6] refactor: style::examples for help examples, and a simpler check Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/main.rs | 11 +++-------- src/product.rs | 10 +++++----- src/style.rs | 5 +++++ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78f4d9d..f9fc53d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Agents learn the CLI from `--help`, which always matches the installed version, - **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 -`. -- **Extra help text goes through `style::help`**, so its headings look like clap's. `help_headings_are_styled_like_clap_s` checks this. +- **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. diff --git a/src/main.rs b/src/main.rs index 029df8b..2b3aac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1448,21 +1448,16 @@ mod tests { } /// Headings in `after_help` look like clap's own, which they only do - /// when written through `style::help`. + /// 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) { - let h = clap::builder::Styles::default().get_header().to_owned(); for text in [cmd.get_after_help(), cmd.get_after_long_help()] .into_iter() .flatten() { - let ansi = text.ansi().to_string(); - for l in text.to_string().lines() { - let heading = !l.starts_with(' ') && l.ends_with(':'); - if heading && !ansi.contains(&format!("{h}{l}{h:#}")) { - plain.push(format!("{path}: {l}")); - } + if text.ansi().to_string() == text.to_string() { + plain.push(path.to_string()); } } for sub in cmd.get_subcommands() { diff --git a/src/product.rs b/src/product.rs index dd874da..502f453 100644 --- a/src/product.rs +++ b/src/product.rs @@ -13,24 +13,24 @@ use std::io::{IsTerminal, Write}; pub enum ProductCommand { /// List public products, or one account's products #[command( - after_help = crate::style::help("Examples:\n source-coop product list my-org --json\n source-coop product list -q climate --format table") + after_help = crate::style::examples(" source-coop product list my-org --json\n source-coop product list -q climate --format table") )] List(ListArgs), /// Show one product - #[command(after_help = crate::style::help("Examples:\n source-coop product view my-org/my-data --json"))] + #[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::help("Examples:\n source-coop product create my-org/my-data -t \"My data\" --visibility public --json\n echo '{\"title\": \"My data\"}' | source-coop product create my-org/my-data -F - --json") + after_help = crate::style::examples(" source-coop product create my-org/my-data -t \"My data\" --visibility public --json\n 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::help("Examples:\n source-coop product edit my-org/my-data -d \"New description\" --json") + 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::help("Examples:\n source-coop product delete my-org/my-data --yes --json"))] + #[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 80da4b5..6306a8e 100644 --- a/src/style.rs +++ b/src/style.rs @@ -45,6 +45,11 @@ pub fn help(text: &str) -> clap::builder::StyledStr { lines.join("\n").into() } +/// A command's "Examples:" help section, one indented command per line. +pub fn examples(lines: &str) -> clap::builder::StyledStr { + help(&format!("Examples:\n{lines}")) +} + /// 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 { From 2cd0c64196132458dfc50b6102cd74ea17aef384 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 22:00:40 -0700 Subject: [PATCH 5/6] refactor: style::examples takes commands and indents them itself Co-Authored-By: Claude Opus 5.5 --- src/product.rs | 16 +++++++++++----- src/style.rs | 7 ++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/product.rs b/src/product.rs index 502f453..febeb0f 100644 --- a/src/product.rs +++ b/src/product.rs @@ -13,24 +13,30 @@ use std::io::{IsTerminal, Write}; pub enum ProductCommand { /// List public products, or one account's products #[command( - after_help = crate::style::examples(" source-coop product list my-org --json\n source-coop product list -q climate --format table") + 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"))] + #[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\n echo '{\"title\": \"My data\"}' | source-coop product create my-org/my-data -F - --json") + 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") + 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"))] + #[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 6306a8e..525cb98 100644 --- a/src/style.rs +++ b/src/style.rs @@ -45,9 +45,10 @@ pub fn help(text: &str) -> clap::builder::StyledStr { lines.join("\n").into() } -/// A command's "Examples:" help section, one indented command per line. -pub fn examples(lines: &str) -> clap::builder::StyledStr { - help(&format!("Examples:\n{lines}")) +/// 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", From 88365a35b86d07771fa9e10950fa94bb479696c6 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 22:03:13 -0700 Subject: [PATCH 6/6] test: help examples must still parse Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 29 +++++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f9fc53d..c974ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Before committing, read the README sections your change touches and fix anything 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 -`. +- **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. 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/src/main.rs b/src/main.rs index 2b3aac3..6e5e995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1472,6 +1472,35 @@ mod tests { ); } + /// 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");