From 9216615d85cad8deb627170f3b85308528c0ca57 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Tue, 7 Apr 2026 23:59:55 -0600 Subject: [PATCH 1/2] Align CLI with latest GraphQL availability terminology --- .gitignore | 2 + README.md | 8 ++- src/commands/availability.rs | 112 ++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/commands/mode.rs | 1 - src/commands/status.rs | 60 ++++++++++--------- src/commands/verdict.rs | 6 ++ src/commands/watch.rs | 73 +++++++++++----------- src/commands/windows.rs | 113 +++++++++++++++++++++++++++++++++++ src/main.rs | 14 +++++ tests/graphql_contract.rs | 19 ++++++ tests/integration.rs | 4 ++ 12 files changed, 349 insertions(+), 65 deletions(-) create mode 100644 src/commands/availability.rs create mode 100644 src/commands/windows.rs create mode 100644 tests/graphql_contract.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..df02083 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target + +PLAN.md diff --git a/README.md b/README.md index ecf6f9d..2870373 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,10 @@ curl -fsSL https://headsdown.app/install.sh | sh # Authenticate (opens browser for approval) hd auth -# Check your current status +# Check your current status and availability hd status +hd availability +hd windows # Set yourself to busy for 2 hours hd busy 2h @@ -54,6 +56,8 @@ 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 whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | @@ -72,6 +76,8 @@ hd watch | `hd alias remove NAME` | Remove an alias | | `hd alias list` | List all aliases | | `hd telemetry on\|off` | Toggle anonymous usage telemetry | +| `hd calibration on\|off\|status` | Manage calibration reporting | +| `hd outcome ` | Report the real task outcome | | `hd completions ` | Generate shell completions (bash, zsh, fish) | ## Duration Formats diff --git a/src/commands/availability.rs b/src/commands/availability.rs new file mode 100644 index 0000000..f9b7035 --- /dev/null +++ b/src/commands/availability.rs @@ -0,0 +1,112 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const AVAILABILITY_QUERY: &str = r#" +query Availability($at: DateTime) { + availability(at: $at) { + inReachableHours + nextTransitionAt + activeWindow { + id + label + mode + startTime + endTime + days + } + nextWindow { + id + label + mode + startTime + endTime + days + } + } +} +"#; + +pub async fn run(api_url: &str, at: Option, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ "at": at }); + let data = client.execute(AVAILABILITY_QUERY, Some(variables)).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["availability"])?); + return Ok(()); + } + + let availability = &data["availability"]; + + println!(); + println!(" {}", format::styled_bold("Availability")); + println!(); + + if let Some(in_hours) = availability["inReachableHours"].as_bool() { + let state = if in_hours { + "Reachable now" + } else { + "Not reachable now" + }; + println!(" {} {}", format::styled_dimmed("State:"), state); + } + + if let Some(label) = availability["activeWindow"]["label"].as_str() { + let mode = availability["activeWindow"]["mode"] + .as_str() + .unwrap_or("UNKNOWN") + .to_uppercase(); + let days = availability["activeWindow"]["days"].as_str().unwrap_or("-"); + let start = availability["activeWindow"]["startTime"] + .as_str() + .unwrap_or("-"); + let end = availability["activeWindow"]["endTime"] + .as_str() + .unwrap_or("-"); + println!( + " {} {} ({})", + format::styled_dimmed("Active:"), + label, + format::color_mode(&mode) + ); + println!( + " {} {} {}-{}", + format::styled_dimmed("Hours:"), + days, + start, + end + ); + } + + if let Some(label) = availability["nextWindow"]["label"].as_str() { + let mode = availability["nextWindow"]["mode"] + .as_str() + .unwrap_or("UNKNOWN") + .to_uppercase(); + println!( + " {} {} ({})", + format::styled_dimmed("Next window:"), + label, + format::color_mode(&mode) + ); + } + + if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Ok(next_at) = next_transition.parse::>() { + println!( + " {} {}", + format::styled_dimmed("Next change:"), + next_at.format("%a %b %-d %l:%M %p UTC") + ); + } + } + + println!(); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bcc7976..80ed7c3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod alias; pub mod auth; +pub mod availability; pub mod calibration_cmd; pub mod doctor; pub mod hooks; @@ -12,3 +13,4 @@ pub mod update; pub mod verdict; pub mod watch; pub mod whoami; +pub mod windows; diff --git a/src/commands/mode.rs b/src/commands/mode.rs index a3513e4..4431e64 100644 --- a/src/commands/mode.rs +++ b/src/commands/mode.rs @@ -135,7 +135,6 @@ pub async fn run(api_url: &str, mode: &str, duration: Option, json: bool let mut input = serde_json::json!({ "mode": mode, - "afk": mode == "OFFLINE", "autoRespond": mode == "BUSY", "status": false, }); diff --git a/src/commands/status.rs b/src/commands/status.rs index 2008d1c..5bfb443 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -15,11 +15,17 @@ query { duration lock } - calendar { - day - endsAt - workHours - offHours + availability { + inReachableHours + nextTransitionAt + activeWindow { + label + mode + } + nextWindow { + label + mode + } } profile { name @@ -39,7 +45,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } let contract = &data["activeContract"]; - let calendar = &data["calendar"]; + let availability = &data["availability"]; let profile = &data["profile"]; let name = profile["name"].as_str().unwrap_or("Unknown"); @@ -88,19 +94,27 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } } - // Work hours info - if let Some(true) = calendar["offHours"].as_bool() { - println!( - " {} {}", - format::styled_dimmed("Schedule:"), - format::styled_dimmed("Off hours") - ); - } else if let Some(day) = calendar["day"].as_str() { - println!( - " {} Work hours ({})", - format::styled_dimmed("Schedule:"), - capitalize(day) - ); + if let Some(in_hours) = availability["inReachableHours"].as_bool() { + let state = if in_hours { + "Reachable now" + } else { + "Not reachable now" + }; + println!(" {} {}", format::styled_dimmed("Availability:"), state); + } + + if let Some(label) = availability["activeWindow"]["label"].as_str() { + println!(" {} {}", format::styled_dimmed("Window:"), label); + } + + if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Ok(next_at) = next_transition.parse::>() { + println!( + " {} {}", + format::styled_dimmed("Next change:"), + next_at.format("%l:%M %p").to_string().trim() + ); + } } if contract["lock"].as_bool() == Some(true) { @@ -114,11 +128,3 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { println!(); Ok(()) } - -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::() + &c.as_str().to_lowercase(), - } -} diff --git a/src/commands/verdict.rs b/src/commands/verdict.rs index 65c1815..3b332f2 100644 --- a/src/commands/verdict.rs +++ b/src/commands/verdict.rs @@ -8,6 +8,8 @@ const SUBMIT_PROPOSAL_MUTATION: &str = r#" mutation SubmitProposal($input: ProposalInput!) { submitProposal(input: $input) { decision + policy + policyStatus reason proposalId evaluatedAt @@ -57,6 +59,8 @@ pub async fn run( .as_str() .unwrap_or("UNKNOWN") .to_uppercase(); + let policy = verdict["policy"].as_str().unwrap_or("UNKNOWN"); + let policy_status = verdict["policyStatus"].as_str().unwrap_or("UNKNOWN"); let reason = verdict["reason"].as_str().unwrap_or("No reason provided"); println!(); @@ -66,6 +70,8 @@ pub async fn run( format::color_verdict(&decision) ); println!(); + println!(" {} {}", format::styled_dimmed("Policy:"), policy); + println!(" {} {}", format::styled_dimmed("State:"), policy_status); println!(" {} {}", format::styled_dimmed("Reason:"), reason); // Show the proposal details diff --git a/src/commands/watch.rs b/src/commands/watch.rs index c3197ab..aae0400 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -17,11 +17,13 @@ query { duration lock } - calendar { - day - endsAt - workHours - offHours + availability { + inReachableHours + nextTransitionAt + activeWindow { + label + mode + } } profile { name @@ -56,7 +58,7 @@ pub async fn run(api_url: &str) -> Result<()> { Ok(data) => { let contract = &data["activeContract"]; let profile = &data["profile"]; - let calendar = &data["calendar"]; + let availability = &data["availability"]; let name = profile["name"].as_str().unwrap_or("Unknown"); let mode = contract["mode"] @@ -67,8 +69,8 @@ pub async fn run(api_url: &str) -> Result<()> { // Clear previous output (move cursor up and clear lines) // On first render, don't clear if !last_mode.is_empty() { - // Move up 6 lines and clear them - for _ in 0..6 { + // Move up 7 lines and clear them + for _ in 0..7 { print!("\x1b[A\x1b[2K"); } } @@ -128,29 +130,36 @@ pub async fn run(api_url: &str) -> Result<()> { println!(" {} -", format::styled_dimmed("Time:")); } - // Schedule - if let Some(true) = calendar["offHours"].as_bool() { - println!( - " {} {}", - format::styled_dimmed("Schedule:"), - format::styled_dimmed("Off hours") - ); - } else if let Some(day) = calendar["day"].as_str() { - println!( - " {} Work hours ({})", - format::styled_dimmed("Schedule:"), - capitalize(day) - ); + if let Some(in_hours) = availability["inReachableHours"].as_bool() { + let state = if in_hours { + "Reachable now" + } else { + "Not reachable now" + }; + println!(" {} {}", format::styled_dimmed("Availability:"), state); } else { - println!(" {} -", format::styled_dimmed("Schedule:")); + println!(" {} -", format::styled_dimmed("Availability:")); } - // Updated at - println!( - " {} {}", - format::styled_dimmed("Updated:"), - format::styled_dimmed(&Utc::now().format("%H:%M:%S").to_string()) - ); + if let Some(label) = availability["activeWindow"]["label"].as_str() { + println!(" {} {}", format::styled_dimmed("Window:"), label); + } else { + println!(" {} -", format::styled_dimmed("Window:")); + } + + if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Ok(next_at) = next_transition.parse::>() { + println!( + " {} {}", + format::styled_dimmed("Next change:"), + format::styled_dimmed(next_at.format("%l:%M %p").to_string().trim()) + ); + } else { + println!(" {} -", format::styled_dimmed("Next change:")); + } + } else { + println!(" {} -", format::styled_dimmed("Next change:")); + } io::stdout().flush().ok(); @@ -185,11 +194,3 @@ pub async fn run(api_url: &str) -> Result<()> { println!(); Ok(()) } - -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::() + &c.as_str().to_lowercase(), - } -} diff --git a/src/commands/windows.rs b/src/commands/windows.rs new file mode 100644 index 0000000..9c61064 --- /dev/null +++ b/src/commands/windows.rs @@ -0,0 +1,113 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const WINDOWS_QUERY: &str = r#" +query { + reachabilityWindows { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +pub async fn run(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 { + println!( + "{}", + serde_json::to_string_pretty(&data["reachabilityWindows"])? + ); + return Ok(()); + } + + let windows = data["reachabilityWindows"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No reachability windows found"))?; + + println!(); + println!(" {}", format::styled_bold("Reachability Windows")); + println!(); + + if windows.is_empty() { + println!(" {}", format::styled_dimmed("No windows configured")); + println!(); + return Ok(()); + } + + 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(""); + + 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("Message:"), + emoji, + status_text + ); + } + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 43546f7..afd0245 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,16 @@ enum Commands { /// Show your current availability status Status, + /// Show your availability resolution + Availability { + /// Optional RFC3339 timestamp to resolve at (defaults to now) + #[arg(long)] + at: Option, + }, + + /// List configured reachability windows + Windows, + /// Show your authenticated identity Whoami, @@ -256,6 +266,8 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { match cli.command { 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::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, @@ -340,6 +352,8 @@ fn command_name(cmd: &Commands) -> &'static str { match cmd { Commands::Auth => "auth", Commands::Status => "status", + Commands::Availability { .. } => "availability", + Commands::Windows => "windows", Commands::Whoami => "whoami", Commands::Busy { .. } => "busy", Commands::Online => "online", diff --git a/tests/graphql_contract.rs b/tests/graphql_contract.rs new file mode 100644 index 0000000..06ab071 --- /dev/null +++ b/tests/graphql_contract.rs @@ -0,0 +1,19 @@ +#[test] +fn command_queries_use_availability_root_field() { + let status_src = include_str!("../src/commands/status.rs"); + let watch_src = include_str!("../src/commands/watch.rs"); + let availability_src = include_str!("../src/commands/availability.rs"); + + assert!( + status_src.contains("availability"), + "status query should reference availability root field" + ); + assert!( + watch_src.contains("availability"), + "watch query should reference availability root field" + ); + assert!( + availability_src.contains("availability"), + "availability query should reference availability root field" + ); +} diff --git a/tests/integration.rs b/tests/integration.rs index 434ca65..04b5a1f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -50,6 +50,8 @@ fn subcommand_help_works() { for cmd in &[ "auth", "status", + "availability", + "windows", "whoami", "busy", "online", @@ -63,6 +65,8 @@ fn subcommand_help_works() { "update", "hook", "telemetry", + "calibration", + "outcome", "alias", "completions", ] { From 8ac80d198becc79cff7f0575194399f68ad8a9e8 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Wed, 8 Apr 2026 00:04:34 -0600 Subject: [PATCH 2/2] Fix CI clippy failure in outcome command --- src/commands/outcome.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/outcome.rs b/src/commands/outcome.rs index 42505f7..6861502 100644 --- a/src/commands/outcome.rs +++ b/src/commands/outcome.rs @@ -20,6 +20,7 @@ mutation ReportOutcome($input: OutcomeInput!) { } "#; +#[allow(clippy::too_many_arguments)] pub async fn run( api_url: &str, proposal_id: &str,