Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ inherits = "release"
lto = "thin"

[dev-dependencies]
shlex = "1.3.0"
wiremock = "0.6.5"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 96 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,28 @@ fn parse_duration(s: &str) -> Result<u64, String> {
.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 <command> --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)]
Expand Down Expand Up @@ -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<String>) {
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<String>) {
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<String>) {
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");
Expand Down
17 changes: 17 additions & 0 deletions src/product.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down
24 changes: 24 additions & 0 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<chrono::Utc>, now: chrono::DateTime<chrono::Utc>) -> String {
Expand Down
Loading