Skip to content

Add corresponding API operation ID to CLI docs JSON#1399

Merged
david-crespo merged 2 commits intomainfrom
cli-op-id-docs
Mar 26, 2026
Merged

Add corresponding API operation ID to CLI docs JSON#1399
david-crespo merged 2 commits intomainfrom
cli-op-id-docs

Conversation

@david-crespo
Copy link
Copy Markdown
Contributor

Closes #875

Was messing with linking to API endpoints corresponding to forms in the console (oxidecomputer/console#3153) and realized it would be nice to just link to the API endpoint and then let the API doc link you to the CLI if you want. Turns out I made an issue for this two years ago.

This can absolutely be done without the unstable-ext approach if you'd rather not, it's just neater with it — feels like the way it wants to be done. The alternative is using the command path to go find the corresponding CliCommand for a given clap Command.

unstable-ext seems relatively stable to me. It hasn't been mentioned in the release notes since August 2024. If they ever messed with it, it would be really easy to deal with.

@david-crespo david-crespo requested a review from ahl March 26, 2026 03:40
Copy link
Copy Markdown
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two quick suggestions. Let's check in via chat tomorrow.

let mut runner = CommandBuilder::default();
for op in CliCommand::iter() {
let Some(path) = xxx(op) else { continue };
runner.add_cmd(path, GeneratedCmd(op));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an alternative approach would be to add a field to GeneratedCmd and then upcast when generating the docs--which is effectively what the clap function must be doing

Copy link
Copy Markdown
Contributor Author

@david-crespo david-crespo Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we build the docs JSON, we start from the clap Command tree, which doesn't know about GeneratedCmd. In order to get to the relevant GeneratedCmd, we need a way of mapping one to the other. In the version of this PR I have that doesn't use unstable-ext (diff below), I use the path string from the Command to look up the matching CliCommand. It's fine, but it's using the paths to back into something we get more or less for free with the extension. Did you have something else in mind?

Example diff without unstable-ext
diff --git a/cli/src/cli_builder.rs b/cli/src/cli_builder.rs
index 86c7b9591d..9210c39e06 100644
--- a/cli/src/cli_builder.rs
+++ b/cli/src/cli_builder.rs
@@ -110,7 +110,7 @@
         let mut parser = OxideCli::command().name("oxide").subcommand_required(true);
         let mut runner = CommandBuilder::default();
         for op in CliCommand::iter() {
-            let Some(path) = xxx(op) else { continue };
+            let Some(path) = op.cli_path() else { continue };
             runner.add_cmd(path, GeneratedCmd(op));
 
             let cmd = Cli::<OxideOverride>::get_command(op);
@@ -393,8 +393,11 @@
     }
 }
 
-fn xxx<'a>(command: CliCommand) -> Option<&'a str> {
-    match command {
+impl CliCommand {
+    /// The space-delimited subcommand path for this operation in the CLI,
+    /// or `None` if it is not exposed as a generated subcommand.
+    pub(crate) fn cli_path(self) -> Option<&'static str> {
+        match self {
         CliCommand::InstanceList => Some("instance list"),
         CliCommand::InstanceCreate => Some("instance create"),
         CliCommand::InstanceView => Some("instance view"),
@@ -822,6 +825,7 @@
         | CliCommand::SystemMetric
         | CliCommand::UserBuiltinList
         | CliCommand::UserBuiltinView => None,
+        }
     }
 }
 
diff --git a/cli/src/cmd_docs.rs b/cli/src/cmd_docs.rs
index f5b9a5f9a8..b677413db4 100644
--- a/cli/src/cmd_docs.rs
+++ b/cli/src/cmd_docs.rs
@@ -4,7 +4,10 @@
 
 // Copyright 2024 Oxide Computer Company
 
+use std::collections::HashMap;
+
 use crate::context::Context;
+use crate::generated_cli::CliCommand;
 use crate::{println_nopipe, RunnableCmd};
 
 use super::cmd_version::built_info;
@@ -50,17 +53,19 @@
     about: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     long_about: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    operation_id: Option<&'static str>,
     #[serde(skip_serializing_if = "Vec::is_empty")]
     args: Vec<JsonArg>,
     #[serde(skip_serializing_if = "Vec::is_empty")]
     subcommands: Vec<JsonDoc>,
 }
 
-fn to_json(cmd: &Command) -> JsonDoc {
+fn to_json(cmd: &Command, op_ids: &HashMap<&str, &'static str>, path: &[&str]) -> JsonDoc {
     let mut subcommands = cmd
         .get_subcommands()
-        .filter(|cmd| cmd.get_name() != "help")
-        .map(to_json)
+        .filter(|sub| sub.get_name() != "help")
+        .map(|sub| to_json(sub, op_ids, &[path, &[sub.get_name()]].concat()))
         .collect::<Vec<_>>();
     subcommands.sort_unstable_by(|a, b| a.name.cmp(&b.name));
     let mut args = cmd
@@ -89,12 +94,14 @@
     } else {
         None
     };
+    let operation_id = op_ids.get(path.join(" ").as_str()).copied();
 
     JsonDoc {
         name,
         version,
         about: cmd.get_about().map(ToString::to_string),
         long_about: cmd.get_long_about().map(ToString::to_string),
+        operation_id,
         args,
         subcommands,
     }
@@ -103,10 +110,13 @@
 #[async_trait]
 impl RunnableCmd for CmdDocs {
     async fn run(&self, _ctx: &Context) -> Result<()> {
+        let op_ids: HashMap<&str, &'static str> = CliCommand::iter()
+            .filter_map(|op| Some((op.cli_path()?, op.operation_id())))
+            .collect();
         let cli = crate::make_cli();
         let mut app = cli.command_take();
         app.build();
-        let json_doc = to_json(&app);
+        let json_doc = to_json(&app, &op_ids, &[]);
         let pretty_json = serde_json::to_string_pretty(&json_doc)?;
         println_nopipe!("{}", pretty_json);
         Ok(())

@ahl
Copy link
Copy Markdown
Collaborator

ahl commented Mar 26, 2026

Also please update copyright years.

@david-crespo david-crespo merged commit b1f34b4 into main Mar 26, 2026
17 checks passed
@david-crespo david-crespo deleted the cli-op-id-docs branch March 26, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI docs: include corresponding API operation ID for each command so we can link to it

2 participants