From 7cdd7267f5d6a82e36cecfb372ca5cdbcb2a9566 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Wed, 8 Apr 2026 00:11:42 -0600 Subject: [PATCH 1/2] Add reachability window create/update/delete commands --- README.md | 6 +- src/commands/windows.rs | 404 +++++++++++++++++++++++++++++++++++----- src/main.rs | 203 +++++++++++++++++++- tests/integration.rs | 17 ++ 4 files changed, 576 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2870373..3e2e0bb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ hd auth hd status hd availability hd windows +hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00 # Set yourself to busy for 2 hours hd busy 2h @@ -57,7 +58,10 @@ hd watch | `hd auth` | Authenticate via Device Flow (browser-based) | | `hd status` | Show your current availability | | `hd availability [--at ]` | Show availability resolution and next transition | -| `hd windows` | List configured reachability windows | +| `hd windows [list]` | List configured reachability windows | +| `hd windows create ...` | Create a reachability window | +| `hd windows update ...` | Update a reachability window | +| `hd windows delete ` | Delete a reachability window | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | diff --git a/src/commands/windows.rs b/src/commands/windows.rs index 9c61064..cc57db2 100644 --- a/src/commands/windows.rs +++ b/src/commands/windows.rs @@ -1,4 +1,5 @@ -use anyhow::Result; +use anyhow::{bail, Result}; +use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; @@ -24,10 +25,85 @@ query { } "#; -pub async fn run(api_url: &str, json: bool) -> Result<()> { +const CREATE_WINDOW_MUTATION: &str = r#" +mutation CreateReachabilityWindow($input: ReachabilityWindowInput!) { + createReachabilityWindow(input: $input) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +const UPDATE_WINDOW_MUTATION: &str = r#" +mutation UpdateReachabilityWindow($id: ID!, $input: ReachabilityWindowUpdateInput!) { + updateReachabilityWindow(id: $id, input: $input) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +const DELETE_WINDOW_MUTATION: &str = r#" +mutation DeleteReachabilityWindow($id: ID!) { + deleteReachabilityWindow(id: $id) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct WindowInputArgs { + pub label: Option, + pub mode: Option, + pub days: Option, + pub start: Option, + pub end: Option, + pub alerts_policy: Option, + pub priority: Option, + pub auto_activate: Option, + pub snooze: Option, + pub status: Option, + pub status_emoji: Option, + pub status_text: Option, +} + +pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(WINDOWS_QUERY, None).await?; if json { @@ -53,61 +129,291 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } for window in windows { - let id = window["id"].as_str().unwrap_or("-"); - let label = window["label"].as_str().unwrap_or("Unnamed"); - let mode = window["mode"].as_str().unwrap_or("UNKNOWN").to_uppercase(); - let days = window["days"].as_str().unwrap_or("-"); - let start = window["startTime"].as_str().unwrap_or("-"); - let end = window["endTime"].as_str().unwrap_or("-"); - let policy = window["alertsPolicy"].as_str().unwrap_or("-"); - let priority = window["priority"].as_i64().unwrap_or_default(); - let auto_activate = window["autoActivate"].as_bool().unwrap_or(false); - let status = window["status"].as_bool().unwrap_or(false); - let emoji = window["statusEmoji"].as_str().unwrap_or(""); - let status_text = window["statusText"].as_str().unwrap_or(""); + print_window(window); + } + Ok(()) +} + +pub async fn create(api_url: &str, args: WindowInputArgs, json: bool) -> Result<()> { + require_create_fields(&args)?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let input = build_input(args); + let variables = serde_json::json!({ "input": input }); + let data = client + .execute(CREATE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {} ({})", - format::styled_dimmed("•"), - format::styled_bold(label), - format::color_mode(&mode) - ); - println!( - " {} {} {}-{}", - format::styled_dimmed("Window:"), - days, - start, - end + "{}", + serde_json::to_string_pretty(&data["createReachabilityWindow"])? ); + return Ok(()); + } + + println!(); + println!(" {} Window created", format::styled_green_bold("✓")); + println!(); + print_window(&data["createReachabilityWindow"]); + Ok(()) +} + +pub async fn update(api_url: &str, id: &str, args: WindowInputArgs, json: bool) -> Result<()> { + if all_fields_empty(&args) { + bail!("No updates provided. Pass at least one field to update."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let input = build_input(args); + let variables = serde_json::json!({ "id": id, "input": input }); + let data = client + .execute(UPDATE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {}", - format::styled_dimmed("Alerts:"), - policy.to_lowercase().replace('_', " ") + "{}", + serde_json::to_string_pretty(&data["updateReachabilityWindow"])? ); + return Ok(()); + } + + println!(); + println!(" {} Window updated", format::styled_green_bold("✓")); + println!(); + print_window(&data["updateReachabilityWindow"]); + Ok(()) +} + +pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ "id": id }); + let data = client + .execute(DELETE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {} {} {} {} {}", - format::styled_dimmed("Priority:"), - priority, - format::styled_dimmed("Auto:"), - auto_activate, - format::styled_dimmed("Status:"), - status + "{}", + serde_json::to_string_pretty(&data["deleteReachabilityWindow"])? ); - if !emoji.is_empty() || !status_text.is_empty() { - println!( - " {} {} {}", - format::styled_dimmed("Message:"), - emoji, - status_text - ); - } + return Ok(()); + } + + let window = &data["deleteReachabilityWindow"]; + let label = window["label"].as_str().unwrap_or("Unnamed"); + + println!(); + println!( + " {} Deleted window {}", + format::styled_green_bold("✓"), + format::styled_bold(label) + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + + Ok(()) +} + +fn require_create_fields(args: &WindowInputArgs) -> Result<()> { + if args.label.is_none() + || args.mode.is_none() + || args.days.is_none() + || args.start.is_none() + || args.end.is_none() + { + bail!("Create requires --label, --mode, --days, --start, and --end."); + } + Ok(()) +} + +fn all_fields_empty(args: &WindowInputArgs) -> bool { + args.label.is_none() + && args.mode.is_none() + && args.days.is_none() + && args.start.is_none() + && args.end.is_none() + && args.alerts_policy.is_none() + && args.priority.is_none() + && args.auto_activate.is_none() + && args.snooze.is_none() + && args.status.is_none() + && args.status_emoji.is_none() + && args.status_text.is_none() +} + +fn build_input(args: WindowInputArgs) -> Value { + let mut input = serde_json::json!({}); + + if let Some(label) = args.label { + input["label"] = serde_json::json!(label); + } + if let Some(mode) = args.mode { + input["mode"] = serde_json::json!(normalize_mode(&mode)); + } + if let Some(days) = args.days { + input["days"] = serde_json::json!(days); + } + if let Some(start) = args.start { + input["startTime"] = serde_json::json!(start); + } + if let Some(end) = args.end { + input["endTime"] = serde_json::json!(end); + } + if let Some(policy) = args.alerts_policy { + input["alertsPolicy"] = serde_json::json!(normalize_alerts_policy(&policy)); + } + if let Some(priority) = args.priority { + input["priority"] = serde_json::json!(priority); + } + if let Some(auto_activate) = args.auto_activate { + input["autoActivate"] = serde_json::json!(auto_activate); + } + if let Some(snooze) = args.snooze { + input["snooze"] = serde_json::json!(snooze); + } + if let Some(status) = args.status { + input["status"] = serde_json::json!(status); + } + if let Some(status_emoji) = args.status_emoji { + input["statusEmoji"] = serde_json::json!(status_emoji); + } + if let Some(status_text) = args.status_text { + input["statusText"] = serde_json::json!(status_text); + } + + input +} + +fn normalize_mode(mode: &str) -> String { + mode.trim().replace('-', "_").to_uppercase() +} + +fn normalize_alerts_policy(policy: &str) -> String { + policy.trim().replace('-', "_").to_uppercase() +} + +fn print_window(window: &Value) { + let id = window["id"].as_str().unwrap_or("-"); + let label = window["label"].as_str().unwrap_or("Unnamed"); + let mode = window["mode"].as_str().unwrap_or("UNKNOWN").to_uppercase(); + let days = window["days"].as_str().unwrap_or("-"); + let start = window["startTime"].as_str().unwrap_or("-"); + let end = window["endTime"].as_str().unwrap_or("-"); + let policy = window["alertsPolicy"].as_str().unwrap_or("-"); + let priority = window["priority"].as_i64().unwrap_or_default(); + let auto_activate = window["autoActivate"].as_bool().unwrap_or(false); + let status = window["status"].as_bool().unwrap_or(false); + let emoji = window["statusEmoji"].as_str().unwrap_or(""); + let status_text = window["statusText"].as_str().unwrap_or(""); + + println!( + " {} {} ({})", + format::styled_dimmed("•"), + format::styled_bold(label), + format::color_mode(&mode) + ); + println!( + " {} {} {}-{}", + format::styled_dimmed("Window:"), + days, + start, + end + ); + println!( + " {} {}", + format::styled_dimmed("Alerts:"), + policy.to_lowercase().replace('_', " ") + ); + println!( + " {} {} {} {} {} {}", + format::styled_dimmed("Priority:"), + priority, + format::styled_dimmed("Auto:"), + auto_activate, + format::styled_dimmed("Status:"), + status + ); + if !emoji.is_empty() || !status_text.is_empty() { println!( - " {} {}", - format::styled_dimmed("ID:"), - format::styled_dimmed(id) + " {} {} {}", + format::styled_dimmed("Message:"), + emoji, + status_text ); - println!(); } + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); +} - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_input_normalizes_enums_and_maps_fields() { + let args = WindowInputArgs { + label: Some("Focus".to_string()), + mode: Some("busy".to_string()), + days: Some("Mon-Fri".to_string()), + start: Some("09:00:00".to_string()), + end: Some("17:00:00".to_string()), + alerts_policy: Some("do_not_disturb".to_string()), + priority: Some(10), + auto_activate: Some(true), + snooze: Some(false), + status: Some(true), + status_emoji: Some("🎧".to_string()), + status_text: Some("Deep work".to_string()), + }; + + let input = build_input(args); + + assert_eq!(input["mode"], "BUSY"); + assert_eq!(input["alertsPolicy"], "DO_NOT_DISTURB"); + assert_eq!(input["startTime"], "09:00:00"); + assert_eq!(input["endTime"], "17:00:00"); + assert_eq!(input["statusEmoji"], "🎧"); + } + + #[test] + fn create_requires_core_fields() { + let args = WindowInputArgs::default(); + assert!(require_create_fields(&args).is_err()); + + let args = WindowInputArgs { + label: Some("Focus".to_string()), + mode: Some("busy".to_string()), + days: Some("Mon-Fri".to_string()), + start: Some("09:00:00".to_string()), + end: Some("17:00:00".to_string()), + ..WindowInputArgs::default() + }; + assert!(require_create_fields(&args).is_ok()); + } + + #[test] + fn all_fields_empty_detects_changes() { + assert!(all_fields_empty(&WindowInputArgs::default())); + assert!(!all_fields_empty(&WindowInputArgs { + priority: Some(5), + ..WindowInputArgs::default() + })); + } } diff --git a/src/main.rs b/src/main.rs index afd0245..6f54e52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,8 +40,11 @@ enum Commands { at: Option, }, - /// List configured reachability windows - Windows, + /// Manage reachability windows + Windows { + #[command(subcommand)] + action: Option, + }, /// Show your authenticated identity Whoami, @@ -172,6 +175,123 @@ enum Commands { }, } +#[derive(Subcommand)] +enum WindowAction { + /// List configured windows + List, + + /// Create a reachability window + Create { + /// Window label + #[arg(long)] + label: String, + + /// Mode (online, busy, limited, offline) + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: String, + + /// Days expression (for example: Mon-Fri) + #[arg(long)] + days: String, + + /// Start time (HH:MM:SS) + #[arg(long)] + start: String, + + /// End time (HH:MM:SS) + #[arg(long)] + end: String, + + /// Alerts policy (off, interruptable, do_not_disturb, take_a_number, after_hours) + #[arg(long, value_parser = ["off", "interruptable", "do_not_disturb", "take_a_number", "after_hours"])] + alerts_policy: Option, + + /// Priority (higher wins) + #[arg(long)] + priority: Option, + + /// Auto activate this window + #[arg(long)] + auto_activate: Option, + + /// Enable snooze for this window + #[arg(long)] + snooze: Option, + + /// Set status enabled/disabled for this window + #[arg(long)] + status: Option, + + /// Optional status emoji + #[arg(long)] + status_emoji: Option, + + /// Optional status text + #[arg(long)] + status_text: Option, + }, + + /// Update a reachability window + Update { + /// Window id + id: String, + + /// Window label + #[arg(long)] + label: Option, + + /// Mode (online, busy, limited, offline) + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: Option, + + /// Days expression (for example: Mon-Fri) + #[arg(long)] + days: Option, + + /// Start time (HH:MM:SS) + #[arg(long)] + start: Option, + + /// End time (HH:MM:SS) + #[arg(long)] + end: Option, + + /// Alerts policy (off, interruptable, do_not_disturb, take_a_number, after_hours) + #[arg(long, value_parser = ["off", "interruptable", "do_not_disturb", "take_a_number", "after_hours"])] + alerts_policy: Option, + + /// Priority (higher wins) + #[arg(long)] + priority: Option, + + /// Auto activate this window + #[arg(long)] + auto_activate: Option, + + /// Enable snooze for this window + #[arg(long)] + snooze: Option, + + /// Set status enabled/disabled for this window + #[arg(long)] + status: Option, + + /// Optional status emoji + #[arg(long)] + status_emoji: Option, + + /// Optional status text + #[arg(long)] + status_text: Option, + }, + + /// Delete a reachability window + Delete { + /// Window id + id: String, + }, +} + #[derive(Subcommand)] enum HookAction { /// Install git hooks in the current repository @@ -267,7 +387,82 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { Commands::Auth => commands::auth::run(&api_url).await, Commands::Status => commands::status::run(&api_url, json).await, Commands::Availability { at } => commands::availability::run(&api_url, at, json).await, - Commands::Windows => commands::windows::run(&api_url, json).await, + Commands::Windows { action } => match action { + None | Some(WindowAction::List) => commands::windows::list(&api_url, json).await, + Some(WindowAction::Create { + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }) => { + commands::windows::create( + &api_url, + commands::windows::WindowInputArgs { + label: Some(label), + mode: Some(mode), + days: Some(days), + start: Some(start), + end: Some(end), + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(WindowAction::Update { + id, + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }) => { + commands::windows::update( + &api_url, + &id, + commands::windows::WindowInputArgs { + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(WindowAction::Delete { id }) => { + commands::windows::delete(&api_url, &id, json).await + } + }, Commands::Whoami => commands::whoami::run(&api_url, json).await, Commands::Busy { duration } => commands::mode::run(&api_url, "BUSY", duration, json).await, Commands::Online => commands::mode::run(&api_url, "ONLINE", None, json).await, @@ -353,7 +548,7 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Auth => "auth", Commands::Status => "status", Commands::Availability { .. } => "availability", - Commands::Windows => "windows", + Commands::Windows { .. } => "windows", Commands::Whoami => "whoami", Commands::Busy { .. } => "busy", Commands::Online => "online", diff --git a/tests/integration.rs b/tests/integration.rs index 04b5a1f..ec0d216 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -79,6 +79,23 @@ fn subcommand_help_works() { } } +#[test] +fn windows_subcommand_help_works() { + for cmd in &[ + ["windows", "list"], + ["windows", "create"], + ["windows", "update"], + ["windows", "delete"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + #[test] fn completions_generates_output() { for shell in &["bash", "zsh", "fish"] { From f636f22e150b56a42d60535227f00012a007f114 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Wed, 8 Apr 2026 00:22:32 -0600 Subject: [PATCH 2/2] Expose digest, presets CRUD, autoresponder, and verdict settings commands --- README.md | 18 +- src/commands/autoresponder.rs | 122 +++++++++++++ src/commands/digest.rs | 148 ++++++++++++++++ src/commands/interrupt.rs | 61 +++++++ src/commands/mod.rs | 5 + src/commands/presets.rs | 296 ++++++++++++++++++++++++++----- src/commands/proposals.rs | 89 ++++++++++ src/commands/verdict_settings.rs | 92 ++++++++++ src/main.rs | 250 ++++++++++++++++++++++++-- tests/integration.rs | 66 +++++++ 10 files changed, 1083 insertions(+), 64 deletions(-) create mode 100644 src/commands/autoresponder.rs create mode 100644 src/commands/digest.rs create mode 100644 src/commands/interrupt.rs create mode 100644 src/commands/proposals.rs create mode 100644 src/commands/verdict_settings.rs diff --git a/README.md b/README.md index 3e2e0bb..9b378aa 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ hd status hd availability hd windows hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00 +hd presets create --name "Deep Focus" --alerts do_not_disturb --presence on_keys --duration 90 +hd digest list --latest 10 +hd autoresponder get # Set yourself to busy for 2 hours hd busy 2h @@ -62,14 +65,25 @@ hd watch | `hd windows create ...` | Create a reachability window | | `hd windows update ...` | Update a reachability window | | `hd windows delete ` | Delete a reachability window | +| `hd presets [list]` | List available presets | +| `hd presets create ...` | Create a preset | +| `hd presets update ...` | Update a preset | +| `hd presets delete ` | Delete a preset | +| `hd preset "name"` | Activate a preset | +| `hd digest [list] [--latest N]` | List digest summaries | +| `hd digest dismiss ` | Dismiss a digest entry | +| `hd autoresponder get` | Show auto-responder settings | +| `hd autoresponder set ...` | Update busy/limited/offline auto-response text | +| `hd verdict-settings get` | Show verdict settings | +| `hd verdict-settings set --mode-thresholds ''` | Update verdict mode thresholds | +| `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals | +| `hd interrupt ` | Evaluate if an interrupt is allowed | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | | `hd offline` | Set mode to offline | | `hd limited [duration]` | Set mode to limited | | `hd verdict "desc"` | Submit a task proposal and get a verdict | -| `hd presets` | List available presets | -| `hd preset "name"` | Activate a preset | | `hd watch` | Live-updating status dashboard | | `hd doctor` | Check CLI health and connectivity | | `hd update` | Self-update to the latest version | diff --git a/src/commands/autoresponder.rs b/src/commands/autoresponder.rs new file mode 100644 index 0000000..633aea1 --- /dev/null +++ b/src/commands/autoresponder.rs @@ -0,0 +1,122 @@ +use anyhow::{bail, Result}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const AUTO_RESPONDER_QUERY: &str = r#" +query { + autoResponderSettings { + id + busyText + limitedText + offlineText + updatedAt + } +} +"#; + +const UPDATE_AUTO_RESPONDER_MUTATION: &str = r#" +mutation UpdateAutoResponderSettings($busyText: String, $limitedText: String, $offlineText: String) { + updateAutoResponderSettings(busyText: $busyText, limitedText: $limitedText, offlineText: $offlineText) { + id + busyText + limitedText + offlineText + updatedAt + } +} +"#; + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client.execute(AUTO_RESPONDER_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["autoResponderSettings"])? + ); + return Ok(()); + } + + let settings = &data["autoResponderSettings"]; + println!(); + println!(" {}", format::styled_bold("Auto-responder")); + println!(); + println!( + " {} {}", + format::styled_dimmed("Busy:"), + settings["busyText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Limited:"), + settings["limitedText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Offline:"), + settings["offlineText"].as_str().unwrap_or("-") + ); + println!(); + Ok(()) +} + +pub async fn set( + api_url: &str, + busy_text: Option, + limited_text: Option, + offline_text: Option, + json: bool, +) -> Result<()> { + if busy_text.is_none() && limited_text.is_none() && offline_text.is_none() { + bail!("No updates provided. Pass at least one of --busy-text, --limited-text, or --offline-text."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "busyText": busy_text, + "limitedText": limited_text, + "offlineText": offline_text, + }); + let data = client + .execute(UPDATE_AUTO_RESPONDER_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["updateAutoResponderSettings"])? + ); + return Ok(()); + } + + println!(); + println!( + " {} Auto-responder updated", + format::styled_green_bold("✓") + ); + println!(); + let settings = &data["updateAutoResponderSettings"]; + println!( + " {} {}", + format::styled_dimmed("Busy:"), + settings["busyText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Limited:"), + settings["limitedText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Offline:"), + settings["offlineText"].as_str().unwrap_or("-") + ); + println!(); + Ok(()) +} diff --git a/src/commands/digest.rs b/src/commands/digest.rs new file mode 100644 index 0000000..cce6040 --- /dev/null +++ b/src/commands/digest.rs @@ -0,0 +1,148 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const DIGEST_QUERY: &str = r#" +query DigestSummaries($latest: Int) { + digestSummaries(latest: $latest) { + id + action + actorLabel + actorRef + channelRef + sourceType + entryCount + firstEventAt + lastEventAt + events { + description + insertedAt + } + } +} +"#; + +const DISMISS_DIGEST_MUTATION: &str = r#" +mutation DismissDigestEntry($id: ID!) { + dismissDigestEntry(id: $id) { + id + action + actorLabel + entryCount + sourceType + } +} +"#; + +pub async fn list(api_url: &str, latest: Option, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ "latest": latest }); + let data = client.execute(DIGEST_QUERY, Some(variables)).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["digestSummaries"])? + ); + return Ok(()); + } + + let entries = data["digestSummaries"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No digest summaries found"))?; + + println!(); + println!(" {}", format::styled_bold("Digest Summaries")); + println!(); + + if entries.is_empty() { + println!(" {}", format::styled_dimmed("No digest entries.")); + println!(); + return Ok(()); + } + + for entry in entries { + let id = entry["id"].as_str().unwrap_or("-"); + let action = entry["action"].as_str().unwrap_or("-"); + let actor = entry["actorLabel"] + .as_str() + .or_else(|| entry["actorRef"].as_str()) + .unwrap_or("Unknown"); + let count = entry["entryCount"].as_i64().unwrap_or_default(); + let source = entry["sourceType"].as_str().unwrap_or("-"); + + println!( + " {} {} ({})", + format::styled_dimmed("•"), + format::styled_bold(actor), + action.to_lowercase() + ); + println!( + " {} {} {} {}", + format::styled_dimmed("Entries:"), + count, + format::styled_dimmed("Source:"), + source.to_lowercase() + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + + if let Some(events) = entry["events"].as_array() { + for event in events.iter().take(2) { + if let Some(description) = event["description"].as_str() { + println!( + " {} {}", + format::styled_dimmed("-"), + format::styled_dimmed(description) + ); + } + } + } + + println!(); + } + + Ok(()) +} + +pub async fn dismiss(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let data = client + .execute( + DISMISS_DIGEST_MUTATION, + Some(serde_json::json!({ "id": id })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["dismissDigestEntry"])? + ); + return Ok(()); + } + + let entry = &data["dismissDigestEntry"]; + println!(); + println!( + " {} Dismissed digest entry", + format::styled_green_bold("✓") + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(entry["id"].as_str().unwrap_or(id)) + ); + println!(); + + Ok(()) +} diff --git a/src/commands/interrupt.rs b/src/commands/interrupt.rs new file mode 100644 index 0000000..a24465b --- /dev/null +++ b/src/commands/interrupt.rs @@ -0,0 +1,61 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const EVALUATE_INTERRUPT_QUERY: &str = r#" +query EvaluateInterrupt($handle: String) { + evaluateInterrupt(handle: $handle) { + allowed + reason + autoResponse + } +} +"#; + +pub async fn evaluate(api_url: &str, handle: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let data = client + .execute( + EVALUATE_INTERRUPT_QUERY, + Some(serde_json::json!({ "handle": handle })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["evaluateInterrupt"])? + ); + return Ok(()); + } + + let result = &data["evaluateInterrupt"]; + let allowed = result["allowed"].as_bool().unwrap_or(false); + + println!(); + println!(" {} {}", format::styled_dimmed("Handle:"), handle); + println!( + " {} {}", + format::styled_dimmed("Allowed:"), + if allowed { + format::styled_green_bold("yes") + } else { + format::styled_yellow_bold("no") + } + ); + if let Some(reason) = result["reason"].as_str() { + println!(" {} {}", format::styled_dimmed("Reason:"), reason); + } + if let Some(response) = result["autoResponse"].as_str() { + if !response.is_empty() { + println!(" {} {}", format::styled_dimmed("Auto-response:"), response); + } + } + println!(); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 80ed7c3..9ede480 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,16 +1,21 @@ pub mod alias; pub mod auth; +pub mod autoresponder; pub mod availability; pub mod calibration_cmd; +pub mod digest; pub mod doctor; pub mod hooks; +pub mod interrupt; pub mod mode; pub mod outcome; pub mod presets; +pub mod proposals; pub mod status; pub mod telemetry_cmd; pub mod update; pub mod verdict; +pub mod verdict_settings; pub mod watch; pub mod whoami; pub mod windows; diff --git a/src/commands/presets.rs b/src/commands/presets.rs index 4f427ed..b56c5f7 100644 --- a/src/commands/presets.rs +++ b/src/commands/presets.rs @@ -1,4 +1,5 @@ -use anyhow::Result; +use anyhow::{bail, Result}; +use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; @@ -12,6 +13,7 @@ query { alerts presence duration + status statusEmoji statusText } @@ -30,7 +32,57 @@ mutation ApplyPreset($id: ID!) { } "#; -pub async fn run(api_url: &str, json: bool) -> Result<()> { +const CREATE_PRESET_MUTATION: &str = r#" +mutation CreatePreset($input: PresetInput!) { + createPreset(input: $input) { + id + name + alerts + presence + duration + status + statusEmoji + statusText + } +} +"#; + +const UPDATE_PRESET_MUTATION: &str = r#" +mutation UpdatePreset($id: ID!, $input: PresetInput!) { + updatePreset(id: $id, input: $input) { + id + name + alerts + presence + duration + status + statusEmoji + statusText + } +} +"#; + +const DELETE_PRESET_MUTATION: &str = r#" +mutation DeletePreset($id: ID!) { + deletePreset(id: $id) { + id + name + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct PresetInputArgs { + pub name: Option, + pub alerts: Option, + pub presence: Option, + pub duration: Option, + pub status: Option, + pub status_emoji: Option, + pub status_text: Option, +} + +pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); @@ -47,7 +99,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { if presets.is_empty() { println!(); - println!(" No presets configured. Create presets in the HeadsDown app."); + println!(" No presets configured. Create one with `hd presets create`."); println!(); return Ok(()); } @@ -57,46 +109,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { println!(); for preset in presets { - let name = preset["name"].as_str().unwrap_or("Unknown"); - let alerts = preset["alerts"].as_str().unwrap_or(""); - let presence = preset["presence"].as_str().unwrap_or(""); - let emoji = preset["statusEmoji"].as_str().unwrap_or(""); - let status_text = preset["statusText"].as_str().unwrap_or(""); - - // Format the preset name with any emoji - let display_name = if !emoji.is_empty() { - format!("{} {}", emoji, name) - } else { - name.to_string() - }; - - print!( - " {} {}", - format::styled_dimmed("•"), - format::styled_bold(&display_name) - ); - - // Show duration if set - if let Some(duration) = preset["duration"].as_i64() { - print!(" ({})", format::format_duration(duration)); - } - - println!(); - - // Show details on second line - let mut details = Vec::new(); - if !alerts.is_empty() { - details.push(format!("alerts: {}", format_enum_value(alerts))); - } - if !presence.is_empty() { - details.push(format!("presence: {}", format_enum_value(presence))); - } - if !status_text.is_empty() { - details.push(format!("\"{}\"", status_text)); - } - if !details.is_empty() { - println!(" {}", format::styled_dimmed(&details.join(" · "))); - } + print_preset(preset); } println!(); @@ -114,13 +127,11 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - // First, list presets to find the matching one let data = client.execute(PRESETS_QUERY, None).await?; let presets = data["presets"] .as_array() .ok_or_else(|| anyhow::anyhow!("No presets found"))?; - // Find by name (case-insensitive) or ID let preset = presets .iter() .find(|p| { @@ -166,11 +177,9 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> format::color_mode(&mode) ); - // Show duration if present if let Some(expires_str) = contract["expiresAt"].as_str() { if let Ok(expires_at) = expires_str.parse::>() { - let now = chrono::Utc::now(); - let remaining = expires_at.signed_duration_since(now); + let remaining = expires_at.signed_duration_since(chrono::Utc::now()); if remaining.num_minutes() > 0 { print!(" for {}", format::format_duration(remaining.num_minutes())); } @@ -183,7 +192,196 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> Ok(()) } -/// Convert SCREAMING_SNAKE_CASE enum values to readable text. +pub async fn create(api_url: &str, args: PresetInputArgs, json: bool) -> Result<()> { + if args.name.is_none() { + bail!("Create requires --name."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let input = build_input(args); + let data = client + .execute( + CREATE_PRESET_MUTATION, + Some(serde_json::json!({ "input": input })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["createPreset"])?); + return Ok(()); + } + + println!(); + println!(" {} Preset created", format::styled_green_bold("✓")); + println!(); + print_preset(&data["createPreset"]); + Ok(()) +} + +pub async fn update(api_url: &str, id: &str, args: PresetInputArgs, json: bool) -> Result<()> { + if all_fields_empty(&args) { + bail!("No updates provided. Pass at least one field to update."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let input = build_input(args); + let data = client + .execute( + UPDATE_PRESET_MUTATION, + Some(serde_json::json!({ "id": id, "input": input })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["updatePreset"])?); + return Ok(()); + } + + println!(); + println!(" {} Preset updated", format::styled_green_bold("✓")); + println!(); + print_preset(&data["updatePreset"]); + Ok(()) +} + +pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client + .execute( + DELETE_PRESET_MUTATION, + Some(serde_json::json!({ "id": id })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["deletePreset"])?); + return Ok(()); + } + + println!(); + println!( + " {} Deleted preset {}", + format::styled_green_bold("✓"), + format::styled_bold(data["deletePreset"]["name"].as_str().unwrap_or("Unknown")) + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + Ok(()) +} + +fn build_input(args: PresetInputArgs) -> Value { + let mut input = serde_json::json!({}); + + if let Some(name) = args.name { + input["name"] = serde_json::json!(name); + } + if let Some(alerts) = args.alerts { + input["alerts"] = serde_json::json!(normalize_enum(&alerts)); + } + if let Some(presence) = args.presence { + input["presence"] = serde_json::json!(normalize_enum(&presence)); + } + if let Some(duration) = args.duration { + input["duration"] = serde_json::json!(duration); + } + if let Some(status) = args.status { + input["status"] = serde_json::json!(status); + } + if let Some(status_emoji) = args.status_emoji { + input["statusEmoji"] = serde_json::json!(status_emoji); + } + if let Some(status_text) = args.status_text { + input["statusText"] = serde_json::json!(status_text); + } + + input +} + +fn all_fields_empty(args: &PresetInputArgs) -> bool { + args.name.is_none() + && args.alerts.is_none() + && args.presence.is_none() + && args.duration.is_none() + && args.status.is_none() + && args.status_emoji.is_none() + && args.status_text.is_none() +} + +fn normalize_enum(input: &str) -> String { + input.trim().replace('-', "_").to_uppercase() +} + +fn print_preset(preset: &Value) { + let id = preset["id"].as_str().unwrap_or("-"); + let name = preset["name"].as_str().unwrap_or("Unknown"); + let alerts = preset["alerts"].as_str().unwrap_or(""); + let presence = preset["presence"].as_str().unwrap_or(""); + let emoji = preset["statusEmoji"].as_str().unwrap_or(""); + let status_text = preset["statusText"].as_str().unwrap_or(""); + + let display_name = if !emoji.is_empty() { + format!("{} {}", emoji, name) + } else { + name.to_string() + }; + + print!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(&display_name) + ); + if let Some(duration) = preset["duration"].as_i64() { + print!(" ({})", format::format_duration(duration)); + } + println!(); + + let mut details = Vec::new(); + if !alerts.is_empty() { + details.push(format!("alerts: {}", format_enum_value(alerts))); + } + if !presence.is_empty() { + details.push(format!("presence: {}", format_enum_value(presence))); + } + if !status_text.is_empty() { + details.push(format!("\"{}\"", status_text)); + } + if !details.is_empty() { + println!(" {}", format::styled_dimmed(&details.join(" · "))); + } + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); +} + fn format_enum_value(s: &str) -> String { s.to_lowercase().replace('_', " ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_enum_maps_to_upper_snake_case() { + assert_eq!(normalize_enum("do_not_disturb"), "DO_NOT_DISTURB"); + assert_eq!(normalize_enum("take-a-number"), "TAKE_A_NUMBER"); + } + + #[test] + fn update_requires_at_least_one_field() { + assert!(all_fields_empty(&PresetInputArgs::default())); + assert!(!all_fields_empty(&PresetInputArgs { + name: Some("Focus".to_string()), + ..PresetInputArgs::default() + })); + } +} diff --git a/src/commands/proposals.rs b/src/commands/proposals.rs new file mode 100644 index 0000000..3c55d39 --- /dev/null +++ b/src/commands/proposals.rs @@ -0,0 +1,89 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const PROPOSALS_QUERY: &str = r#" +query Proposals($latest: Int, $verdict: VerdictDecision) { + proposals(latest: $latest, verdict: $verdict) { + id + description + estimatedFiles + estimatedMinutes + model + framework + verdict + verdictReason + insertedAt + } +} +"#; + +pub async fn list( + api_url: &str, + latest: Option, + verdict: Option, + json: bool, +) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let verdict = verdict.map(|v| v.to_uppercase()); + let variables = serde_json::json!({ "latest": latest, "verdict": verdict }); + let data = client.execute(PROPOSALS_QUERY, Some(variables)).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["proposals"])?); + return Ok(()); + } + + let proposals = data["proposals"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No proposals found"))?; + + println!(); + println!(" {}", format::styled_bold("Recent proposals")); + println!(); + + if proposals.is_empty() { + println!(" {}", format::styled_dimmed("No proposals found.")); + println!(); + return Ok(()); + } + + for proposal in proposals { + let id = proposal["id"].as_str().unwrap_or("-"); + let desc = proposal["description"].as_str().unwrap_or("-"); + let decision = proposal["verdict"] + .as_str() + .unwrap_or("UNKNOWN") + .to_uppercase(); + let reason = proposal["verdictReason"].as_str().unwrap_or("-"); + let files = proposal["estimatedFiles"].as_i64().unwrap_or_default(); + let minutes = proposal["estimatedMinutes"].as_i64().unwrap_or_default(); + + println!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(desc) + ); + println!( + " {} {} {} ~{} files / ~{} min", + format::styled_dimmed("Verdict:"), + format::color_verdict(&decision), + format::styled_dimmed("Scope:"), + files, + minutes + ); + println!(" {} {}", format::styled_dimmed("Reason:"), reason); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + } + + Ok(()) +} diff --git a/src/commands/verdict_settings.rs b/src/commands/verdict_settings.rs new file mode 100644 index 0000000..17979bb --- /dev/null +++ b/src/commands/verdict_settings.rs @@ -0,0 +1,92 @@ +use anyhow::{Context, Result}; +use serde_json::Value; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const VERDICT_SETTINGS_QUERY: &str = r#" +query { + verdictSettings { + id + modeThresholds + updatedAt + } +} +"#; + +const UPDATE_VERDICT_SETTINGS_MUTATION: &str = r#" +mutation UpdateVerdictSettings($modeThresholds: JSON) { + updateVerdictSettings(modeThresholds: $modeThresholds) { + id + modeThresholds + updatedAt + } +} +"#; + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client.execute(VERDICT_SETTINGS_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["verdictSettings"])? + ); + return Ok(()); + } + + let settings = &data["verdictSettings"]; + println!(); + println!(" {}", format::styled_bold("Verdict settings")); + println!(); + println!( + " {} {}", + format::styled_dimmed("ID:"), + settings["id"].as_str().unwrap_or("-") + ); + println!(" {}", format::styled_dimmed("Mode thresholds:")); + println!( + "{}", + serde_json::to_string_pretty(&settings["modeThresholds"])? + ); + println!(); + Ok(()) +} + +pub async fn set(api_url: &str, mode_thresholds: &str, json: bool) -> Result<()> { + let parsed: Value = + serde_json::from_str(mode_thresholds).context("mode_thresholds must be valid JSON")?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client + .execute( + UPDATE_VERDICT_SETTINGS_MUTATION, + Some(serde_json::json!({ "modeThresholds": parsed })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["updateVerdictSettings"])? + ); + return Ok(()); + } + + println!(); + println!( + " {} Verdict settings updated", + format::styled_green_bold("✓") + ); + println!(); + println!( + "{}", + serde_json::to_string_pretty(&data["updateVerdictSettings"]["modeThresholds"])? + ); + println!(); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 6f54e52..2ad2275 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,53 @@ enum Commands { action: Option, }, + /// Manage preset configurations + Presets { + #[command(subcommand)] + action: Option, + }, + + /// Apply a preset by name or ID + Preset { + /// Preset name or ID + name: String, + }, + + /// Manage digest summaries + Digest { + #[command(subcommand)] + action: Option, + }, + + /// Manage auto-responder text + Autoresponder { + #[command(subcommand)] + action: Option, + }, + + /// Manage verdict threshold settings + VerdictSettings { + #[command(subcommand)] + action: Option, + }, + + /// List recent task proposals + Proposals { + /// Number of latest proposals to fetch + #[arg(long)] + latest: Option, + + /// Filter by verdict decision (approved, deferred) + #[arg(long, value_parser = ["approved", "deferred"])] + verdict: Option, + }, + + /// Evaluate whether interrupting someone is allowed + Interrupt { + /// User handle to evaluate + handle: String, + }, + /// Show your authenticated identity Whoami, @@ -85,15 +132,6 @@ enum Commands { model: Option, }, - /// List available presets - Presets, - - /// Activate a preset by name or ID - Preset { - /// Preset name or ID - name: String, - }, - /// Live-updating status dashboard Watch, @@ -292,6 +330,94 @@ enum WindowAction { }, } +#[derive(Subcommand)] +enum PresetsAction { + /// List configured presets + List, + + /// Create a preset + Create { + #[arg(long)] + name: String, + #[arg(long)] + alerts: Option, + #[arg(long)] + presence: Option, + #[arg(long)] + duration: Option, + #[arg(long)] + status: Option, + #[arg(long)] + status_emoji: Option, + #[arg(long)] + status_text: Option, + }, + + /// Update a preset by id + Update { + id: String, + #[arg(long)] + name: Option, + #[arg(long)] + alerts: Option, + #[arg(long)] + presence: Option, + #[arg(long)] + duration: Option, + #[arg(long)] + status: Option, + #[arg(long)] + status_emoji: Option, + #[arg(long)] + status_text: Option, + }, + + /// Delete a preset by id + Delete { id: String }, +} + +#[derive(Subcommand)] +enum DigestAction { + /// List recent digest summaries + List { + /// Number of latest summaries to fetch + #[arg(long)] + latest: Option, + }, + + /// Dismiss a digest entry by id + Dismiss { id: String }, +} + +#[derive(Subcommand)] +enum AutoResponderAction { + /// Show current auto-responder settings + Get, + + /// Update auto-responder text templates + Set { + #[arg(long)] + busy_text: Option, + #[arg(long)] + limited_text: Option, + #[arg(long)] + offline_text: Option, + }, +} + +#[derive(Subcommand)] +enum VerdictSettingsAction { + /// Show current verdict settings + Get, + + /// Update mode thresholds JSON + Set { + /// JSON object for mode thresholds + #[arg(long)] + mode_thresholds: String, + }, +} + #[derive(Subcommand)] enum HookAction { /// Install git hooks in the current repository @@ -463,6 +589,101 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { commands::windows::delete(&api_url, &id, json).await } }, + Commands::Presets { action } => match action { + None | Some(PresetsAction::List) => commands::presets::list(&api_url, json).await, + Some(PresetsAction::Create { + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }) => { + commands::presets::create( + &api_url, + commands::presets::PresetInputArgs { + name: Some(name), + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(PresetsAction::Update { + id, + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }) => { + commands::presets::update( + &api_url, + &id, + commands::presets::PresetInputArgs { + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(PresetsAction::Delete { id }) => { + commands::presets::delete(&api_url, &id, json).await + } + }, + Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, + Commands::Digest { action } => match action { + None | Some(DigestAction::List { latest: None }) => { + commands::digest::list(&api_url, None, json).await + } + Some(DigestAction::List { latest }) => { + commands::digest::list(&api_url, latest, json).await + } + Some(DigestAction::Dismiss { id }) => { + commands::digest::dismiss(&api_url, &id, json).await + } + }, + Commands::Autoresponder { action } => match action { + None | Some(AutoResponderAction::Get) => { + commands::autoresponder::get(&api_url, json).await + } + Some(AutoResponderAction::Set { + busy_text, + limited_text, + offline_text, + }) => { + commands::autoresponder::set(&api_url, busy_text, limited_text, offline_text, json) + .await + } + }, + Commands::VerdictSettings { action } => match action { + None | Some(VerdictSettingsAction::Get) => { + commands::verdict_settings::get(&api_url, json).await + } + Some(VerdictSettingsAction::Set { mode_thresholds }) => { + commands::verdict_settings::set(&api_url, &mode_thresholds, json).await + } + }, + Commands::Proposals { latest, verdict } => { + commands::proposals::list(&api_url, latest, verdict, json).await + } + Commands::Interrupt { handle } => { + commands::interrupt::evaluate(&api_url, &handle, json).await + } Commands::Whoami => commands::whoami::run(&api_url, json).await, Commands::Busy { duration } => commands::mode::run(&api_url, "BUSY", duration, json).await, Commands::Online => commands::mode::run(&api_url, "ONLINE", None, json).await, @@ -476,8 +697,6 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { minutes, model, } => commands::verdict::run(&api_url, &description, files, minutes, model, json).await, - Commands::Presets => commands::presets::run(&api_url, json).await, - Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, Commands::Watch => commands::watch::run(&api_url).await, Commands::Doctor => commands::doctor::run(&api_url, json).await, Commands::Update => commands::update::run().await, @@ -549,14 +768,19 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Status => "status", Commands::Availability { .. } => "availability", Commands::Windows { .. } => "windows", + Commands::Presets { .. } => "presets", + Commands::Preset { .. } => "preset", + Commands::Digest { .. } => "digest", + Commands::Autoresponder { .. } => "autoresponder", + Commands::VerdictSettings { .. } => "verdict-settings", + Commands::Proposals { .. } => "proposals", + Commands::Interrupt { .. } => "interrupt", Commands::Whoami => "whoami", Commands::Busy { .. } => "busy", Commands::Online => "online", Commands::Offline => "offline", Commands::Limited { .. } => "limited", Commands::Verdict { .. } => "verdict", - Commands::Presets => "presets", - Commands::Preset { .. } => "preset", Commands::Watch => "watch", Commands::Doctor => "doctor", Commands::Update => "update", diff --git a/tests/integration.rs b/tests/integration.rs index ec0d216..fd925ad 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -52,6 +52,11 @@ fn subcommand_help_works() { "status", "availability", "windows", + "digest", + "autoresponder", + "verdict-settings", + "proposals", + "interrupt", "whoami", "busy", "online", @@ -96,6 +101,67 @@ fn windows_subcommand_help_works() { } } +#[test] +fn presets_subcommand_help_works() { + for cmd in &[ + ["presets", "list"], + ["presets", "create"], + ["presets", "update"], + ["presets", "delete"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn digest_subcommand_help_works() { + for cmd in &[ + ["digest", "list"], + ["digest", "dismiss"], + ["autoresponder", "get"], + ["autoresponder", "set"], + ["verdict-settings", "get"], + ["verdict-settings", "set"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn windows_update_without_fields_fails_with_helpful_message() { + Command::cargo_bin("hd") + .unwrap() + .args(["windows", "update", "window_123"]) + .assert() + .failure() + .stderr(predicate::str::contains("No updates provided")); +} + +#[test] +fn windows_create_missing_required_args_fails_at_parse() { + Command::cargo_bin("hd") + .unwrap() + .args([ + "windows", "create", "--label", "Focus", "--mode", "busy", "--days", "Mon-Fri", + "--start", "09:00:00", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "required arguments were not provided", + )); +} + #[test] fn completions_generates_output() { for shell in &["bash", "zsh", "fish"] {