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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- summary: |
Use safe OpenAPI root tag descriptions for generated CLI group help and agent
skills when no x-fern-groups summary is provided. Match operation-declared
tags to groups without borrowing ambiguous or scope-style descriptions, and
keep CLI help table lines concise while preserving full prose in long help.
Treat summaries that only restate a group's command name as non-informative
when a usable tag description exists, while retaining them when no such
description is available. Preserve framework built-in group descriptions
when a colliding API group has only the generic fallback. Method help now
keeps the fuller description under `<method> --help` while the command
table stays a single short sentence, and sentence splitting no longer cuts
help text at abbreviations such as `e.g.`, `i.e.`, or `U.S.`. A tag named
after the group owns that group's description: when it documents nothing,
a sibling tag's prose no longer stands in for it, and a tag the group
declares less often than another never names the whole group. An operation's
`description` is no longer discarded in favour of its terse `summary`: the
summary still labels the command table while the prose is what the command
renders under its own `--help` — but only when it elaborates rather than
paraphrasing the summary, so the two tiers never read as different
commands. Flag help is split the same way — `-h`
shows a one-line form and `--help` the fuller prose — and multipart field
help, which previously bypassed truncation entirely, is now capped and has
its spec indentation collapsed like every other parameter. `--help` keeps
whatever the spec documents in full; only `-h` trims. An explicit
`x-fern-groups.description` now drives the group's command-table line too
(first sentence, with the full prose kept in long help) instead of only
populating long help while the table showed the generic label.
type: feat
52 changes: 51 additions & 1 deletion generators/cli/sdk/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co
// are intercepted pre-clap anyway, so the built-in wins.
return cli.mut_subcommand(name, move |_spec_owned| builtin);
}
let builtin_about = builtin.get_about().map(ToString::to_string);
let builtin_long_about = builtin.get_long_about().map(ToString::to_string);
let generic_about = crate::openapi::commands::generic_group_about(&name);
cli.mut_subcommand(name, move |spec_owned| {
let mut merged = spec_owned;
let spec_uses_generic_about = merged
.get_about()
.is_none_or(|about| about.to_string() == generic_about);
if spec_uses_generic_about {
if let Some(about) = builtin_about {
merged = merged.about(about);
}
if let Some(long_about) = builtin_long_about {
merged = merged.long_about(long_about);
}
}
for sub in builtin_subs {
merged = crate::custom_commands::graft_subcommand(merged, &[], sub);
}
Expand Down Expand Up @@ -1770,13 +1784,49 @@ mod tests {
assert!(login.get_arguments().any(|a| a.get_id() == "with-token"));
}

#[test]
fn graft_builtin_about_wins_over_generic_spec_fallback() {
let spec = clap::Command::new("root").subcommand(
clap::Command::new("auth")
.about(crate::openapi::commands::generic_group_about("auth"))
.subcommand(clap::Command::new("me")),
);
let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command());
let auth = cli.find_subcommand("auth").expect("auth group survives");
assert_eq!(
auth.get_about().map(ToString::to_string).as_deref(),
Some("Manage credentials (login / logout / status)"),
);
}

#[test]
fn graft_builtin_preserves_real_spec_about() {
let spec = clap::Command::new("root").subcommand(
clap::Command::new("auth")
.about("API authentication operations")
.subcommand(clap::Command::new("me")),
);
let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command());
let auth = cli.find_subcommand("auth").expect("auth group survives");
assert_eq!(
auth.get_about().map(ToString::to_string).as_deref(),
Some("API authentication operations"),
);
}

#[test]
fn graft_builtin_registers_when_no_collision() {
let cli = graft_builtin_command(
clap::Command::new("root").subcommand(clap::Command::new("users")),
crate::auth::login::build_auth_command(),
);
assert!(cli.find_subcommand("auth").is_some());
assert_eq!(
cli.find_subcommand("auth")
.and_then(|auth| auth.get_about())
.map(ToString::to_string)
.as_deref(),
Some("Manage credentials (login / logout / status)"),
);
assert!(cli.find_subcommand("users").is_some());
}

Expand Down
123 changes: 123 additions & 0 deletions generators/cli/sdk/src/openapi/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,61 @@ fn merge_security_schemes(
}
}

/// Merge document-root OpenAPI tag descriptions across specs. First write
/// wins on normalized-name collisions, preserving deterministic metadata when
/// multiple specs declare the same tag.
fn merge_tag_descriptions(
acc: &mut HashMap<String, String>,
incoming: HashMap<String, String>,
) {
for (name, description) in incoming {
acc.entry(name).or_insert(description);
}
}

fn merge_group_tag_names(
acc: &mut HashMap<String, Vec<String>>,
incoming: HashMap<String, Vec<String>>,
) {
for (group, tags) in incoming {
let existing = acc.entry(group).or_default();
for tag in tags {
if !existing.iter().any(|existing_tag| existing_tag == &tag) {
existing.push(tag);
}
}
}
}

fn merge_group_tag_operation_counts(
acc: &mut HashMap<String, HashMap<String, usize>>,
incoming: HashMap<String, HashMap<String, usize>>,
) {
for (group, tags) in incoming {
let existing = acc.entry(group).or_default();
for (tag, count) in tags {
*existing.entry(tag).or_default() += count;
}
}
}

fn merge_group_operation_counts(
acc: &mut HashMap<String, usize>,
incoming: HashMap<String, usize>,
) {
for (group, count) in incoming {
*acc.entry(group).or_default() += count;
}
}

fn merge_tag_description_order(acc: &mut Vec<String>, incoming: Vec<String>) {
for tag in incoming {
if !acc.iter().any(|existing| existing == &tag) {
acc.push(tag);
}
}
}

/// Merge `x-fern-sdk-variables` declarations across specs. First write
/// wins on name collisions, mirroring [`merge_schemas`] and
/// [`merge_security_schemes`]. Multi-spec setups that share a common
Expand Down Expand Up @@ -1189,6 +1244,21 @@ impl CliApp {
merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?;
merge_schemas(&mut acc.schemas, spec_doc.schemas)?;
merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes);
merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions);
merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names);
merge_group_tag_operation_counts(
&mut acc.group_tag_operation_counts,
spec_doc.group_tag_operation_counts,
);
merge_group_operation_counts(
&mut acc.group_operation_counts,
spec_doc.group_operation_counts,
);
merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names);
merge_tag_description_order(
&mut acc.tag_description_order,
spec_doc.tag_description_order,
);
merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables);
merge_global_headers(&mut acc.global_headers, spec_doc.global_headers);
merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters);
Expand Down Expand Up @@ -4321,6 +4391,9 @@ openapi: "3.0.0"
info:
title: "API A"
version: "1.0"
tags:
- name: users
description: User operations.
servers:
- url: "https://api-a.example.com"
paths:
Expand All @@ -4337,6 +4410,9 @@ openapi: "3.0.0"
info:
title: "API B"
version: "1.0"
tags:
- name: orders
description: Order operations.
servers:
- url: "https://api-b.example.com"
paths:
Expand All @@ -4352,6 +4428,53 @@ paths:
let doc = app.build_doc().unwrap();
assert!(doc.resources.contains_key("users"));
assert!(doc.resources.contains_key("orders"));
assert_eq!(
doc.tag_descriptions.get("users").map(String::as_str),
Some("User operations."),
);
assert_eq!(
doc.tag_descriptions.get("orders").map(String::as_str),
Some("Order operations."),
);
}

#[test]
fn test_multi_spec_tag_descriptions_first_write_wins() {
let spec_a = r#"
openapi: "3.0.0"
info: { title: "API A", version: "1.0" }
tags:
- name: shared
description: First description.
paths:
/users:
get:
x-fern-sdk-group-name: ["users"]
x-fern-sdk-method-name: list
responses: { "200": { description: ok } }
"#;
let spec_b = r#"
openapi: "3.0.0"
info: { title: "API B", version: "1.0" }
tags:
- name: shared
description: Second description.
paths:
/orders:
get:
x-fern-sdk-group-name: ["orders"]
x-fern-sdk-method-name: list
responses: { "200": { description: ok } }
"#;
let doc = CliApp::new("test")
.spec(spec_a)
.spec(spec_b)
.build_doc()
.unwrap();
assert_eq!(
doc.tag_descriptions.get("shared").map(String::as_str),
Some("First description."),
);
}

#[test]
Expand Down
Loading
Loading