From 22c261134dc6894acf307c638af989030f2cc9ed Mon Sep 17 00:00:00 2001 From: Clyde C Brown Date: Thu, 22 Oct 2026 22:02:34 -0600 Subject: [PATCH] #2663: lightcraft-cli commands: list commands sorted by id `lightcraft-cli commands` (text and --json) now sorts the registry by id before printing, so each family's commands sit together. The engine's registry order, used by engine.commands over MCP and the control channel, is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 6 +++++- apps/lightcraft-cli/tests/cli.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index c8371cbb9..a27e97beb 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -935,7 +935,11 @@ fn snapshot(args: &[String]) -> Result<(), String> { fn commands(args: &[String]) -> Result<(), String> { let mut h = Headless::demo(); - let cmds = h.call("engine.commands", json!({}))?; + let mut cmds = h.call("engine.commands", json!({}))?; + // Listed by id so each family (`photo.*`, `mask.*`, …) reads together; the registry keeps its own order. + if let Some(list) = cmds.as_array_mut() { + list.sort_by(|a, b| a["id"].as_str().unwrap_or("").cmp(b["id"].as_str().unwrap_or(""))); + } let mut out = std::io::stdout().lock(); if args.iter().any(|a| a == "--json") { writeln!(out, "{}", serde_json::to_string_pretty(&cmds).unwrap_or_default()).map_err(|e| e.to_string())?; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 8f56bafe6..1c8910d9d 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -286,6 +286,38 @@ fn commands_subcommand_lists_registry() { assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure")); } +#[test] +fn commands_subcommand_lists_commands_sorted_by_id() { + let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); + assert!(o.status.success()); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + let cmds = v.as_array().unwrap(); + for c in cmds { + for field in ["id", "label", "menu", "shortcut", "params", "enabled"] { + assert!(c.get(field).is_some(), "every command should keep its {field}: {c}"); + } + } + let ids: Vec<&str> = cmds.iter().filter_map(|c| c["id"].as_str()).collect(); + assert_eq!(ids.len(), cmds.len(), "every command should have a string id"); + let unsorted = ids.windows(2).find(|w| w[0] >= w[1]); + assert!(unsorted.is_none(), "--json should list commands sorted by id, but {unsorted:?} are out of order"); + + let o = Command::new(BIN).arg("commands").output().unwrap(); + assert!(o.status.success()); + let text = String::from_utf8_lossy(&o.stdout); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2 * ids.len(), "each command should take an id line and a params line"); + let listed: Vec<&str> = lines.iter().step_by(2).filter_map(|l| l.split_whitespace().next()).collect(); + assert_eq!(listed, ids, "the text output should list the same commands in the same order as --json"); + assert!(lines.iter().skip(1).step_by(2).all(|l| l.trim_start().starts_with("params:")), "each id line should be followed by its params line"); + for (line, c) in lines.iter().step_by(2).zip(cmds) { + assert!(line.contains(c["label"].as_str().unwrap()), "{line:?} should show the label of {c}"); + if let Some(sc) = c["shortcut"].as_str() { + assert!(line.contains(&format!("[{sc}]")), "{line:?} should show the shortcut of {c}"); + } + } +} + #[test] fn snapshot_subcommand_renders_the_ui_headlessly() { let script = tmp("snap.jsonl");