diff --git a/.github/workflows/contract-smoke.yml b/.github/workflows/contract-smoke.yml new file mode 100644 index 0000000..4319a6e --- /dev/null +++ b/.github/workflows/contract-smoke.yml @@ -0,0 +1,20 @@ +name: Contract Smoke + +on: + pull_request: + branches: [main] + push: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + contract-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --test graphql_contract --verbose + - run: cargo test --test backend_parity --verbose diff --git a/README.md b/README.md index 9b378aa..50ab322 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ 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 +hd grants list-active +hd override get # Set yourself to busy for 2 hours hd busy 2h @@ -46,6 +47,14 @@ hd online # Submit a task for verdict hd verdict "refactor auth module" --files 5 --minutes 30 +# Tune verdict settings +hd verdict-settings set --default-wrap-up-mode wrap_up --wrap-up-threshold-minutes 25 +hd verdict-settings set --thresholds '{"online":{"maxFiles":8,"maxEstimatedMinutes":90}}' + +# Delegation grants and temporary overrides +hd grants create --scope workspace --workspace-ref "$PWD" --permissions preset_apply +hd override set --mode busy --duration-minutes 30 --reason "heads-down coding" + # List and activate presets hd presets hd preset "Focusing" @@ -62,22 +71,21 @@ hd watch | `hd status` | Show your current availability | | `hd availability [--at ]` | Show availability resolution and next transition | | `hd windows [list]` | List configured reachability windows | -| `hd windows create ...` | Create a reachability window | +| `hd windows create ...` | Create a reachability window (supports days like `Mon-Fri` or `Mon,Wed,Fri`) | | `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 verdict-settings set --thresholds ''` | Update verdict threshold settings | | `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals | | `hd interrupt ` | Evaluate if an interrupt is allowed | +| `hd grants [list-active\|list\|create\|revoke\|revoke-many]` | Manage delegation grants | +| `hd override [get\|set\|clear]` | Manage temporary availability overrides | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | @@ -156,7 +164,7 @@ The CLI uses [Device Flow](https://www.rfc-editor.org/rfc/rfc8628) authenticatio 4. Approve the request 5. The CLI stores your API key locally -Credentials are stored at `~/.config/headsdown/credentials` (XDG-compliant, respects `$XDG_CONFIG_HOME`). +Credentials are stored at `~/.config/headsdown/credentials.json` (XDG-compliant, respects `$XDG_CONFIG_HOME`). A legacy `credentials` token file is still read for backwards compatibility. ## Configuration diff --git a/src/auth.rs b/src/auth.rs index 963c325..11df39c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,27 +1,55 @@ use anyhow::{bail, Context, Result}; use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -/// Returns the path to the credentials file, respecting XDG_CONFIG_HOME. -fn credentials_path() -> Result { - let dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg).join("headsdown") +#[derive(Deserialize, Serialize)] +struct JsonCredentials { + #[serde(rename = "apiKey")] + api_key: String, + #[serde(rename = "createdAt")] + created_at: String, + label: Option, +} + +/// Returns the path to the config directory, respecting XDG_CONFIG_HOME. +fn config_dir() -> Result { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + Ok(PathBuf::from(xdg).join("headsdown")) } else if let Some(proj) = ProjectDirs::from("app", "headsdown", "headsdown") { - proj.config_dir().to_path_buf() + Ok(proj.config_dir().to_path_buf()) } else { bail!("Could not determine config directory"); - }; + } +} + +fn legacy_credentials_path() -> Result { + Ok(config_dir()?.join("credentials")) +} - Ok(dir.join("credentials")) +fn json_credentials_path() -> Result { + Ok(config_dir()?.join("credentials.json")) } -/// Load the stored API key, if any. +/// Load the stored API key, if any. Supports both legacy plain-token credentials and modern credentials.json. pub fn load_token() -> Result> { - let path = credentials_path()?; - if path.exists() { - let contents = fs::read_to_string(&path) - .with_context(|| format!("Failed to read {}", path.display()))?; + let json_path = json_credentials_path()?; + if json_path.exists() { + let contents = fs::read_to_string(&json_path) + .with_context(|| format!("Failed to read {}", json_path.display()))?; + if let Ok(parsed) = serde_json::from_str::(&contents) { + let token = parsed.api_key.trim().to_string(); + if !token.is_empty() { + return Ok(Some(token)); + } + } + } + + let legacy_path = legacy_credentials_path()?; + if legacy_path.exists() { + let contents = fs::read_to_string(&legacy_path) + .with_context(|| format!("Failed to read {}", legacy_path.display()))?; let token = contents.trim().to_string(); if token.is_empty() { Ok(None) @@ -33,15 +61,32 @@ pub fn load_token() -> Result> { } } -/// Store the API key to the credentials file. +/// Store the API key to credentials.json (modern format) and legacy credentials for backwards compatibility. pub fn store_token(token: &str) -> Result<()> { - let path = credentials_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } + let dir = config_dir()?; + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create directory {}", dir.display()))?; + + let trimmed = token.trim(); - // Write with restrictive permissions (owner read/write only) + let json_path = json_credentials_path()?; + let json_payload = JsonCredentials { + api_key: trimmed.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + label: Some("HeadsDown CLI".to_string()), + }; + write_secure( + &json_path, + &(serde_json::to_string_pretty(&json_payload)? + "\n"), + )?; + + let legacy_path = legacy_credentials_path()?; + write_secure(&legacy_path, trimmed)?; + + Ok(()) +} + +fn write_secure(path: &PathBuf, contents: &str) -> Result<()> { #[cfg(unix)] { use std::io::Write; @@ -51,14 +96,14 @@ pub fn store_token(token: &str) -> Result<()> { .create(true) .truncate(true) .mode(0o600) - .open(&path) + .open(path) .with_context(|| format!("Failed to write {}", path.display()))?; - file.write_all(token.as_bytes())?; + file.write_all(contents.as_bytes())?; } #[cfg(not(unix))] { - fs::write(&path, token).with_context(|| format!("Failed to write {}", path.display()))?; + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display()))?; } Ok(()) @@ -103,33 +148,16 @@ mod tests { #[test] #[serial] - fn store_creates_parent_directories() { - with_temp_config(|| { - // Temp dir has no headsdown/ subdir yet - store_token("hd_test").unwrap(); - assert!(credentials_path().unwrap().exists()); - }); - } - - #[test] - #[serial] - fn load_returns_none_for_empty_file() { - with_temp_config(|| { - let path = credentials_path().unwrap(); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "").unwrap(); - assert_eq!(load_token().unwrap(), None); - }); - } - - #[test] - #[serial] - fn load_returns_none_for_whitespace_only_file() { + fn load_reads_json_credentials_when_present() { with_temp_config(|| { - let path = credentials_path().unwrap(); + let path = json_credentials_path().unwrap(); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, " \n ").unwrap(); - assert_eq!(load_token().unwrap(), None); + fs::write( + &path, + r#"{"apiKey":"hd_from_json","createdAt":"2026-01-01T00:00:00Z"}"#, + ) + .unwrap(); + assert_eq!(load_token().unwrap(), Some("hd_from_json".to_string())); }); } @@ -142,24 +170,6 @@ mod tests { }); } - #[test] - #[serial] - fn require_token_returns_token_when_present() { - with_temp_config(|| { - store_token("hd_xyz").unwrap(); - assert_eq!(require_token().unwrap(), "hd_xyz"); - }); - } - - #[test] - #[serial] - fn store_empty_string_then_load_returns_none() { - with_temp_config(|| { - store_token("").unwrap(); - assert_eq!(load_token().unwrap(), None); - }); - } - #[cfg(unix)] #[test] #[serial] @@ -167,8 +177,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; with_temp_config(|| { store_token("hd_secret").unwrap(); - let path = credentials_path().unwrap(); - let metadata = fs::metadata(&path).unwrap(); + let metadata = fs::metadata(json_credentials_path().unwrap()).unwrap(); let mode = metadata.permissions().mode() & 0o777; assert_eq!(mode, 0o600, "Credentials file should be owner-only (0600)"); }); diff --git a/src/client.rs b/src/client.rs index c149290..4008dd5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ use anyhow::{bail, Context, Result}; use reqwest::Client; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::Duration; @@ -10,6 +11,7 @@ pub struct GraphQLClient { client: Client, endpoint: String, token: String, + actor_context: Option, } #[derive(Serialize)] @@ -35,6 +37,15 @@ const INITIAL_BACKOFF_MS: u64 = 500; impl GraphQLClient { pub fn new(api_url: &str, token: &str) -> Self { + let workspace_ref = std::env::current_dir() + .ok() + .map(|path| path.display().to_string()); + let actor_context = serde_json::json!({ + "source": "headsdown-cli", + "agentId": "headsdown-cli", + "workspaceRef": workspace_ref, + }); + Self { client: Client::builder() .timeout(Duration::from_secs(30)) @@ -42,6 +53,7 @@ impl GraphQLClient { .unwrap_or_default(), endpoint: format!("{}/graphql", api_url), token: token.to_string(), + actor_context: Some(actor_context.to_string()), } } @@ -81,12 +93,28 @@ impl GraphQLClient { Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries"))) } + /// Execute a GraphQL query/mutation and deserialize the data payload into a typed structure. + pub async fn execute_typed( + &self, + query: &str, + variables: Option, + ) -> Result { + let data = self.execute(query, variables).await?; + serde_json::from_value(data).context("Failed to decode API response shape") + } + async fn send_request(&self, request: &GraphQLRequest) -> Result { - let response = self + let mut request_builder = self .client .post(&self.endpoint) .header("Authorization", format!("Bearer {}", self.token)) - .header("Content-Type", "application/json") + .header("Content-Type", "application/json"); + + if let Some(actor_context) = &self.actor_context { + request_builder = request_builder.header("x-headsdown-actor-context", actor_context); + } + + let response = request_builder .json(request) .send() .await @@ -242,4 +270,34 @@ mod tests { let err = client.execute("query { fail }", None).await.unwrap_err(); assert!(err.to_string().contains("No data returned")); } + + #[tokio::test] + async fn execute_typed_deserializes_data_payload() { + #[derive(Deserialize)] + struct TypedResponse { + profile: Profile, + } + + #[derive(Deserialize)] + struct Profile { + name: String, + } + + let (server, client) = setup().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"profile": {"name": "Alice"}} + }))) + .expect(1) + .mount(&server) + .await; + + let data: TypedResponse = client + .execute_typed("query { profile { name } }", None) + .await + .unwrap(); + assert_eq!(data.profile.name, "Alice"); + } } diff --git a/src/commands/availability.rs b/src/commands/availability.rs index f9b7035..b3d074a 100644 --- a/src/commands/availability.rs +++ b/src/commands/availability.rs @@ -1,8 +1,10 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::{format_days, AvailabilityResolution}; use crate::format; const AVAILABILITY_QUERY: &str = r#" @@ -30,74 +32,82 @@ query Availability($at: DateTime) { } "#; +#[derive(Deserialize, Serialize)] +struct AvailabilityResponse { + availability: Option, +} + 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?; + let data: AvailabilityResponse = client + .execute_typed(AVAILABILITY_QUERY, Some(variables)) + .await?; + let availability = data + .availability + .ok_or_else(|| anyhow!("No availability found"))?; if json { - println!("{}", serde_json::to_string_pretty(&data["availability"])?); + println!("{}", serde_json::to_string_pretty(&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("-"); + if let Some(in_hours) = availability.in_reachable_hours { println!( - " {} {} ({})", - format::styled_dimmed("Active:"), - label, - format::color_mode(&mode) - ); - println!( - " {} {} {}-{}", - format::styled_dimmed("Hours:"), - days, - start, - end + " {} {}", + format::styled_dimmed("State:"), + if in_hours { + "Reachable now" + } else { + "Not reachable now" + } ); } - 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(active_window) = availability.active_window { + if let Some(label) = active_window.label { + let mode = active_window + .mode + .unwrap_or_else(|| "UNKNOWN".to_string()) + .to_uppercase(); + println!( + " {} {} ({})", + format::styled_dimmed("Active:"), + label, + format::color_mode(&mode) + ); + println!( + " {} {} {}-{}", + format::styled_dimmed("Hours:"), + format_days(active_window.days.as_ref()), + active_window.start_time.unwrap_or_else(|| "-".to_string()), + active_window.end_time.unwrap_or_else(|| "-".to_string()) + ); + } + } + + if let Some(next_window) = availability.next_window { + if let Some(label) = next_window.label { + let mode = next_window + .mode + .unwrap_or_else(|| "UNKNOWN".to_string()) + .to_uppercase(); + println!( + " {} {} ({})", + format::styled_dimmed("Next window:"), + label, + format::color_mode(&mode) + ); + } } - if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Some(next_transition) = availability.next_transition_at { if let Ok(next_at) = next_transition.parse::>() { println!( " {} {}", diff --git a/src/commands/grants.rs b/src/commands/grants.rs new file mode 100644 index 0000000..66fc3a9 --- /dev/null +++ b/src/commands/grants.rs @@ -0,0 +1,334 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const ACTIVE_GRANTS_QUERY: &str = r#" +query ActiveDelegationGrants { + activeDelegationGrants { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const GRANTS_QUERY: &str = r#" +query DelegationGrants($filter: DelegationGrantFilterInput) { + delegationGrants(filter: $filter) { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const CREATE_GRANT_MUTATION: &str = r#" +mutation CreateDelegationGrant($input: DelegationGrantInput!) { + createDelegationGrant(input: $input) { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const REVOKE_GRANT_MUTATION: &str = r#" +mutation RevokeDelegationGrant($id: ID!) { + revokeDelegationGrant(id: $id) { + id + scope + expiresAt + revokedAt + } +} +"#; + +const REVOKE_MANY_MUTATION: &str = r#" +mutation RevokeDelegationGrants($filter: DelegationGrantFilterInput) { + revokeDelegationGrants(filter: $filter) { + revokedCount + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct GrantsFilterArgs { + pub active: Option, + pub scope: Option, + pub session_id: Option, + pub workspace_ref: Option, + pub agent_id: Option, + pub source: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct CreateGrantArgs { + pub scope: Option, + pub session_id: Option, + pub workspace_ref: Option, + pub agent_id: Option, + pub permissions: Vec, + pub duration_minutes: Option, + pub expires_at: Option, + pub source: Option, +} + +#[derive(Deserialize, Serialize)] +struct DelegationGrant { + id: String, + scope: String, + #[serde(rename = "expiresAt")] + expires_at: Option, + permissions: Option>, +} + +#[derive(Deserialize)] +struct ActiveGrantsResponse { + #[serde(rename = "activeDelegationGrants")] + active_delegation_grants: Vec, +} + +#[derive(Deserialize)] +struct GrantsResponse { + #[serde(rename = "delegationGrants")] + delegation_grants: Vec, +} + +#[derive(Deserialize)] +struct CreateGrantResponse { + #[serde(rename = "createDelegationGrant")] + create_delegation_grant: DelegationGrant, +} + +#[derive(Deserialize, Serialize)] +struct RevokedGrant { + id: String, + scope: String, +} + +#[derive(Deserialize)] +struct RevokeGrantResponse { + #[serde(rename = "revokeDelegationGrant")] + revoke_delegation_grant: RevokedGrant, +} + +#[derive(Deserialize, Serialize)] +struct RevokeManyResult { + #[serde(rename = "revokedCount")] + revoked_count: i64, +} + +#[derive(Deserialize)] +struct RevokeManyResponse { + #[serde(rename = "revokeDelegationGrants")] + revoke_delegation_grants: RevokeManyResult, +} + +pub async fn list_active(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: ActiveGrantsResponse = client.execute_typed(ACTIVE_GRANTS_QUERY, None).await?; + output_grants(&data.active_delegation_grants, json) +} + +pub async fn list(api_url: &str, filter: GrantsFilterArgs, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "filter": { + "active": filter.active, + "scope": filter.scope.map(|v| v.to_uppercase()), + "sessionId": filter.session_id, + "workspaceRef": filter.workspace_ref, + "agentId": filter.agent_id, + "source": filter.source, + } + }); + + let data: GrantsResponse = client.execute_typed(GRANTS_QUERY, Some(variables)).await?; + output_grants(&data.delegation_grants, json) +} + +pub async fn create(api_url: &str, args: CreateGrantArgs, json: bool) -> Result<()> { + let scope = args + .scope + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--scope is required for grants create"))? + .to_uppercase(); + + if args.permissions.is_empty() { + bail!("--permissions is required for grants create"); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "input": { + "scope": scope, + "sessionId": args.session_id, + "workspaceRef": args.workspace_ref, + "agentId": args.agent_id, + "permissions": args.permissions.into_iter().map(|v| v.to_uppercase()).collect::>(), + "durationMinutes": args.duration_minutes, + "expiresAt": args.expires_at, + "source": args.source.unwrap_or_else(|| "hd".to_string()), + } + }); + + let data: CreateGrantResponse = client + .execute_typed(CREATE_GRANT_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.create_delegation_grant)? + ); + return Ok(()); + } + + println!(); + println!(" {} Grant created", format::styled_green_bold("✓")); + println!( + " {} {}", + format::styled_dimmed("ID:"), + data.create_delegation_grant.id + ); + println!( + " {} {}", + format::styled_dimmed("Scope:"), + data.create_delegation_grant.scope + ); + println!(); + Ok(()) +} + +pub async fn revoke(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: RevokeGrantResponse = client + .execute_typed(REVOKE_GRANT_MUTATION, Some(serde_json::json!({ "id": id }))) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.revoke_delegation_grant)? + ); + return Ok(()); + } + + println!(); + println!(" {} Grant revoked", format::styled_green_bold("✓")); + println!(" {} {}", format::styled_dimmed("ID:"), id); + println!(); + Ok(()) +} + +pub async fn revoke_many(api_url: &str, filter: GrantsFilterArgs, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "filter": { + "active": filter.active, + "scope": filter.scope.map(|v| v.to_uppercase()), + "sessionId": filter.session_id, + "workspaceRef": filter.workspace_ref, + "agentId": filter.agent_id, + "source": filter.source, + } + }); + + let data: RevokeManyResponse = client + .execute_typed(REVOKE_MANY_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.revoke_delegation_grants)? + ); + return Ok(()); + } + + println!(); + println!( + " {} Revoked {} grants", + format::styled_green_bold("✓"), + data.revoke_delegation_grants.revoked_count + ); + println!(); + Ok(()) +} + +fn output_grants(grants: &[DelegationGrant], json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(grants)?); + return Ok(()); + } + + println!(); + println!(" {}", format::styled_bold("Delegation Grants")); + println!(); + + if grants.is_empty() { + println!(" {}", format::styled_dimmed("No grants found.")); + println!(); + return Ok(()); + } + + for grant in grants { + println!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(&grant.id) + ); + println!(" {} {}", format::styled_dimmed("Scope:"), grant.scope); + println!( + " {} {}", + format::styled_dimmed("Expires:"), + grant.expires_at.clone().unwrap_or_else(|| "-".to_string()) + ); + + if let Some(perms) = &grant.permissions { + let joined = perms.join(", "); + if !joined.is_empty() { + println!(" {} {}", format::styled_dimmed("Permissions:"), joined); + } + } + + println!(); + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9ede480..a984af9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,10 +5,12 @@ pub mod availability; pub mod calibration_cmd; pub mod digest; pub mod doctor; +pub mod grants; pub mod hooks; pub mod interrupt; pub mod mode; pub mod outcome; +pub mod override_cmd; pub mod presets; pub mod proposals; pub mod status; diff --git a/src/commands/override_cmd.rs b/src/commands/override_cmd.rs new file mode 100644 index 0000000..80866cb --- /dev/null +++ b/src/commands/override_cmd.rs @@ -0,0 +1,223 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const ACTIVE_OVERRIDE_QUERY: &str = r#" +query ActiveAvailabilityOverride { + activeAvailabilityOverride { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +const CREATE_OVERRIDE_MUTATION: &str = r#" +mutation CreateAvailabilityOverride($input: AvailabilityOverrideInput!) { + createAvailabilityOverride(input: $input) { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +const CANCEL_OVERRIDE_MUTATION: &str = r#" +mutation CancelAvailabilityOverride($id: ID!, $reason: String, $source: String) { + cancelAvailabilityOverride(id: $id, reason: $reason, source: $source) { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +#[derive(Deserialize)] +struct ActiveOverrideResponse { + #[serde(rename = "activeAvailabilityOverride")] + active_availability_override: Option, +} + +#[derive(Deserialize)] +struct CreateOverrideResponse { + #[serde(rename = "createAvailabilityOverride")] + create_availability_override: AvailabilityOverride, +} + +#[derive(Deserialize)] +struct CancelOverrideResponse { + #[serde(rename = "cancelAvailabilityOverride")] + cancel_availability_override: AvailabilityOverride, +} + +#[derive(Deserialize, Serialize, Clone)] +struct AvailabilityOverride { + id: String, + mode: String, + #[serde(rename = "expiresAt")] + expires_at: Option, + #[serde(rename = "cancelledAt")] + cancelled_at: Option, +} + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: ActiveOverrideResponse = client.execute_typed(ACTIVE_OVERRIDE_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.active_availability_override)? + ); + return Ok(()); + } + + println!(); + let current = if let Some(value) = data.active_availability_override { + value + } else { + println!(" {}", format::styled_dimmed("No active override.")); + println!(); + return Ok(()); + }; + + println!(" {}", format::styled_bold("Active Override")); + println!(" {} {}", format::styled_dimmed("ID:"), current.id); + println!(" {} {}", format::styled_dimmed("Mode:"), current.mode); + println!( + " {} {}", + format::styled_dimmed("Expires:"), + current.expires_at.unwrap_or_else(|| "-".to_string()) + ); + println!(); + Ok(()) +} + +pub async fn set( + api_url: &str, + mode: Option, + duration_minutes: Option, + expires_at: Option, + reason: Option, + json: bool, +) -> Result<()> { + let mode = mode.ok_or_else(|| anyhow::anyhow!("--mode is required for override set"))?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: CreateOverrideResponse = client + .execute_typed( + CREATE_OVERRIDE_MUTATION, + Some(serde_json::json!({ + "input": { + "mode": mode.to_uppercase(), + "durationMinutes": duration_minutes, + "expiresAt": expires_at, + "reason": reason, + "source": "hd", + } + })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.create_availability_override)? + ); + return Ok(()); + } + + println!(); + println!(" {} Override set", format::styled_green_bold("✓")); + println!( + " {} {}", + format::styled_dimmed("Mode:"), + data.create_availability_override.mode + ); + println!(); + Ok(()) +} + +pub async fn clear( + api_url: &str, + id: Option, + reason: Option, + json: bool, +) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let override_id = if let Some(value) = id { + value + } else { + let data: ActiveOverrideResponse = + client.execute_typed(ACTIVE_OVERRIDE_QUERY, None).await?; + let active = if let Some(value) = data.active_availability_override { + value + } else { + if json { + println!("{}", serde_json::json!({ "override": null })); + } else { + println!(); + println!( + " {}", + format::styled_dimmed("No active override to clear.") + ); + println!(); + } + return Ok(()); + }; + active.id + }; + + if override_id.is_empty() { + bail!("Override id is required"); + } + + let data: CancelOverrideResponse = client + .execute_typed( + CANCEL_OVERRIDE_MUTATION, + Some(serde_json::json!({ + "id": override_id, + "reason": reason, + "source": "hd", + })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.cancel_availability_override)? + ); + return Ok(()); + } + + println!(); + println!(" {} Override cleared", format::styled_green_bold("✓")); + println!(); + Ok(()) +} diff --git a/src/commands/presets.rs b/src/commands/presets.rs index b56c5f7..7332eb4 100644 --- a/src/commands/presets.rs +++ b/src/commands/presets.rs @@ -1,5 +1,5 @@ -use anyhow::{bail, Result}; -use serde_json::Value; +use anyhow::Result; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; @@ -10,12 +10,12 @@ query { presets { id name - alerts - presence - duration status statusEmoji statusText + duration + insertedAt + updatedAt } } "#; @@ -23,83 +23,66 @@ query { const APPLY_PRESET_MUTATION: &str = r#" mutation ApplyPreset($id: ID!) { applyPreset(id: $id) { - mode - expiresAt - duration - statusText - statusEmoji - } -} -"#; - -const CREATE_PRESET_MUTATION: &str = r#" -mutation CreatePreset($input: PresetInput!) { - createPreset(input: $input) { id - name - alerts - presence - duration + mode status statusEmoji statusText + autoRespond + lock + duration + ruleSetType + ruleSetParams + expiresAt + insertedAt } } "#; -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 - } +#[derive(Deserialize)] +struct PresetsResponse { + presets: Vec, } -"#; -const DELETE_PRESET_MUTATION: &str = r#" -mutation DeletePreset($id: ID!) { - deletePreset(id: $id) { - id - name - } +#[derive(Deserialize, Serialize, Clone)] +struct Preset { + id: String, + name: String, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "statusText")] + status_text: Option, + duration: Option, +} + +#[derive(Deserialize)] +struct ApplyPresetResponse { + #[serde(rename = "applyPreset")] + apply_preset: AppliedPreset, } -"#; -#[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, +#[derive(Deserialize, Serialize)] +struct AppliedPreset { + id: String, + mode: String, + #[serde(rename = "expiresAt")] + expires_at: 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(PRESETS_QUERY, None).await?; + let data: PresetsResponse = client.execute_typed(PRESETS_QUERY, None).await?; if json { - println!("{}", serde_json::to_string_pretty(&data["presets"])?); + println!("{}", serde_json::to_string_pretty(&data.presets)?); return Ok(()); } - let presets = data["presets"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("No presets found"))?; - - if presets.is_empty() { + if data.presets.is_empty() { println!(); - println!(" No presets configured. Create one with `hd presets create`."); + println!(" No presets configured."); println!(); return Ok(()); } @@ -108,7 +91,7 @@ pub async fn list(api_url: &str, json: bool) -> Result<()> { println!(" {}", format::styled_bold("Available Presets")); println!(); - for preset in presets { + for preset in &data.presets { print_preset(preset); } @@ -127,20 +110,15 @@ 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); - let data = client.execute(PRESETS_QUERY, None).await?; - let presets = data["presets"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("No presets found"))?; + let data: PresetsResponse = client.execute_typed(PRESETS_QUERY, None).await?; - let preset = presets + let preset = data + .presets .iter() - .find(|p| { - let pname = p["name"].as_str().unwrap_or(""); - let pid = p["id"].as_str().unwrap_or(""); - pname.eq_ignore_ascii_case(name_or_id) || pid == name_or_id - }) + .find(|p| p.name.eq_ignore_ascii_case(name_or_id) || p.id == name_or_id) + .cloned() .ok_or_else(|| { - let names: Vec<&str> = presets.iter().filter_map(|p| p["name"].as_str()).collect(); + let names: Vec = data.presets.iter().map(|p| p.name.clone()).collect(); anyhow::anyhow!( "Preset '{}' not found. Available: {}", name_or_id, @@ -148,36 +126,27 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> ) })?; - let preset_id = preset["id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Preset missing ID"))?; - let preset_name = preset["name"].as_str().unwrap_or(name_or_id); - - let variables = serde_json::json!({ "id": preset_id }); - let data = client - .execute(APPLY_PRESET_MUTATION, Some(variables)) + let variables = serde_json::json!({ "id": preset.id }); + let applied: ApplyPresetResponse = client + .execute_typed(APPLY_PRESET_MUTATION, Some(variables)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&data["applyPreset"])?); + println!("{}", serde_json::to_string_pretty(&applied.apply_preset)?); return Ok(()); } - let contract = &data["applyPreset"]; - let mode = contract["mode"] - .as_str() - .unwrap_or("UNKNOWN") - .to_uppercase(); + let mode = applied.apply_preset.mode.to_uppercase(); println!(); print!( " {} Applied preset \"{}\" - now {}", format::styled_green_bold("✓"), - format::styled_bold(preset_name), + format::styled_bold(&preset.name), format::color_mode(&mode) ); - if let Some(expires_str) = contract["expiresAt"].as_str() { + if let Some(expires_str) = applied.apply_preset.expires_at { if let Ok(expires_at) = expires_str.parse::>() { let remaining = expires_at.signed_duration_since(chrono::Utc::now()); if remaining.num_minutes() > 0 { @@ -192,144 +161,11 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> Ok(()) } -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) +fn print_preset(preset: &Preset) { + let display_name = if let Some(emoji) = &preset.status_emoji { + format!("{} {}", emoji, preset.name) } else { - name.to_string() + preset.name.clone() }; print!( @@ -337,51 +173,40 @@ fn print_preset(preset: &Value) { format::styled_dimmed("•"), format::styled_bold(&display_name) ); - if let Some(duration) = preset["duration"].as_i64() { + if let Some(duration) = preset.duration { 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(" · "))); + if let Some(status_text) = &preset.status_text { + if !status_text.is_empty() { + println!( + " {}", + format::styled_dimmed(&format!("\"{}\"", status_text)) + ); + } } println!( " {} {}", format::styled_dimmed("ID:"), - format::styled_dimmed(id) + format::styled_dimmed(&preset.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() - })); + fn preset_lookup_is_case_insensitive() { + let list = serde_json::json!([ + {"id":"1","name":"Focus"}, + {"id":"2","name":"Meetings"} + ]); + let presets = list.as_array().unwrap(); + + let found = presets + .iter() + .find(|p| p["name"].as_str().unwrap().eq_ignore_ascii_case("focus")) + .unwrap(); + assert_eq!(found["id"], "1"); } } diff --git a/src/commands/status.rs b/src/commands/status.rs index 5bfb443..298e3d1 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,8 +1,10 @@ use anyhow::Result; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::AvailabilityResolution; use crate::format; const STATUS_QUERY: &str = r#" @@ -33,32 +35,60 @@ query { } "#; +#[derive(Deserialize, Serialize)] +struct StatusResponse { + #[serde(rename = "activeContract")] + active_contract: Option, + availability: Option, + profile: Option, +} + +#[derive(Deserialize, Serialize)] +struct ActiveContract { + mode: Option, + #[serde(rename = "statusText")] + status_text: Option, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "expiresAt")] + expires_at: Option, + lock: Option, +} + +#[derive(Deserialize, Serialize)] +struct Profile { + name: Option, +} + 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(STATUS_QUERY, None).await?; + let data: StatusResponse = client.execute_typed(STATUS_QUERY, None).await?; if json { println!("{}", serde_json::to_string_pretty(&data)?); return Ok(()); } - let contract = &data["activeContract"]; - let availability = &data["availability"]; - let profile = &data["profile"]; - - let name = profile["name"].as_str().unwrap_or("Unknown"); - let mode = contract["mode"] - .as_str() - .unwrap_or("UNKNOWN") + let contract = data.active_contract; + let availability = data.availability; + let profile = data.profile; + + let name = profile + .and_then(|p| p.name) + .unwrap_or_else(|| "Unknown".to_string()); + let mode = contract + .as_ref() + .and_then(|c| c.mode.clone()) + .unwrap_or_else(|| "UNKNOWN".to_string()) .to_uppercase(); println!(); println!( " {} {}", format::styled_bold("●"), - format::styled_bold(name) + format::styled_bold(&name) ); println!(); println!( @@ -67,47 +97,52 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { format::color_mode(&mode) ); - // Status text - if let Some(emoji) = contract["statusEmoji"].as_str() { - if let Some(text) = contract["statusText"].as_str() { + if let Some(text) = contract.as_ref().and_then(|c| c.status_text.clone()) { + if let Some(emoji) = contract.as_ref().and_then(|c| c.status_emoji.clone()) { println!(" {} {} {}", format::styled_dimmed("Status:"), emoji, text); + } else { + println!(" {} {}", format::styled_dimmed("Status:"), text); } - } else if let Some(text) = contract["statusText"].as_str() { - println!(" {} {}", format::styled_dimmed("Status:"), text); } - // Duration / expires at - if let Some(expires_str) = contract["expiresAt"].as_str() { + if let Some(expires_str) = contract.as_ref().and_then(|c| c.expires_at.clone()) { if let Ok(expires_at) = expires_str.parse::>() { - let now = Utc::now(); - let remaining = expires_at.signed_duration_since(now); - + let remaining = expires_at.signed_duration_since(Utc::now()); if remaining.num_minutes() > 0 { - let formatted = format::format_duration(remaining.num_minutes()); println!( " {} {} remaining (until {})", format::styled_dimmed("Time:"), - format::styled_bold(&formatted), + format::styled_bold(&format::format_duration(remaining.num_minutes())), expires_at.format("%l:%M %p").to_string().trim() ); } } } - 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(in_hours) = availability.as_ref().and_then(|a| a.in_reachable_hours) { + println!( + " {} {}", + format::styled_dimmed("Availability:"), + if in_hours { + "Reachable now" + } else { + "Not reachable now" + } + ); } - if let Some(label) = availability["activeWindow"]["label"].as_str() { + if let Some(label) = availability + .as_ref() + .and_then(|a| a.active_window.as_ref()) + .and_then(|w| w.label.clone()) + { println!(" {} {}", format::styled_dimmed("Window:"), label); } - if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Some(next_transition) = availability + .as_ref() + .and_then(|a| a.next_transition_at.clone()) + { if let Ok(next_at) = next_transition.parse::>() { println!( " {} {}", @@ -117,7 +152,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } } - if contract["lock"].as_bool() == Some(true) { + if contract.as_ref().and_then(|c| c.lock) == Some(true) { println!( " {} {}", format::styled_dimmed("Lock:"), diff --git a/src/commands/verdict.rs b/src/commands/verdict.rs index 3b332f2..606051a 100644 --- a/src/commands/verdict.rs +++ b/src/commands/verdict.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; @@ -8,15 +9,48 @@ const SUBMIT_PROPOSAL_MUTATION: &str = r#" mutation SubmitProposal($input: ProposalInput!) { submitProposal(input: $input) { decision - policy - policyStatus reason proposalId evaluatedAt + wrapUpGuidance { + active + deadlineAt + remainingMinutes + profile + source + reason + hints + thresholdMinutes + selectedMode + } } } "#; +#[derive(Deserialize)] +struct SubmitProposalResponse { + #[serde(rename = "submitProposal")] + submit_proposal: Verdict, +} + +#[derive(Deserialize, Serialize)] +struct Verdict { + decision: String, + reason: String, + #[serde(rename = "proposalId")] + proposal_id: String, + #[serde(rename = "wrapUpGuidance")] + wrap_up_guidance: Option, +} + +#[derive(Deserialize, Serialize)] +struct WrapUpGuidance { + #[serde(rename = "selectedMode")] + selected_mode: Option, + #[serde(rename = "remainingMinutes")] + remaining_minutes: Option, +} + pub async fn run( api_url: &str, description: &str, @@ -45,23 +79,18 @@ pub async fn run( } let variables = serde_json::json!({ "input": input }); - let data = client - .execute(SUBMIT_PROPOSAL_MUTATION, Some(variables)) + let data: SubmitProposalResponse = client + .execute_typed(SUBMIT_PROPOSAL_MUTATION, Some(variables)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&data["submitProposal"])?); + println!("{}", serde_json::to_string_pretty(&data.submit_proposal)?); return Ok(()); } - let verdict = &data["submitProposal"]; - let decision = verdict["decision"] - .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"); + let verdict = data.submit_proposal; + let decision = verdict.decision.to_uppercase(); + let reason = verdict.reason; println!(); println!( @@ -70,10 +99,21 @@ 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); + if let Some(wrap_up_guidance) = verdict.wrap_up_guidance { + if let Some(mode) = wrap_up_guidance.selected_mode { + println!(" {} {}", format::styled_dimmed("Delivery mode:"), mode); + } + if let Some(minutes) = wrap_up_guidance.remaining_minutes { + println!( + " {} {} min", + format::styled_dimmed("Attention window:"), + minutes + ); + } + } + // Show the proposal details println!(); println!(" {} {}", format::styled_dimmed("Task:"), description); diff --git a/src/commands/verdict_settings.rs b/src/commands/verdict_settings.rs index 17979bb..fb70063 100644 --- a/src/commands/verdict_settings.rs +++ b/src/commands/verdict_settings.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::auth; @@ -9,70 +10,148 @@ const VERDICT_SETTINGS_QUERY: &str = r#" query { verdictSettings { id - modeThresholds + thresholds { + online { + maxFiles + maxEstimatedMinutes + } + busy { + maxFiles + maxEstimatedMinutes + } + limited { + maxFiles + maxEstimatedMinutes + } + offline { + maxFiles + maxEstimatedMinutes + } + } + defaultWrapUpMode + wrapUpThresholdMinutes updatedAt } } "#; const UPDATE_VERDICT_SETTINGS_MUTATION: &str = r#" -mutation UpdateVerdictSettings($modeThresholds: JSON) { - updateVerdictSettings(modeThresholds: $modeThresholds) { +mutation UpdateVerdictSettings($thresholds: VerdictModeThresholdsInput, $defaultWrapUpMode: WrapUpMode, $wrapUpThresholdMinutes: Int) { + updateVerdictSettings(thresholds: $thresholds, defaultWrapUpMode: $defaultWrapUpMode, wrapUpThresholdMinutes: $wrapUpThresholdMinutes) { id - modeThresholds + thresholds { + online { + maxFiles + maxEstimatedMinutes + } + busy { + maxFiles + maxEstimatedMinutes + } + limited { + maxFiles + maxEstimatedMinutes + } + offline { + maxFiles + maxEstimatedMinutes + } + } + defaultWrapUpMode + wrapUpThresholdMinutes updatedAt } } "#; +#[derive(Deserialize)] +struct VerdictSettingsResponse { + #[serde(rename = "verdictSettings")] + verdict_settings: VerdictSettings, +} + +#[derive(Deserialize)] +struct UpdateVerdictSettingsResponse { + #[serde(rename = "updateVerdictSettings")] + update_verdict_settings: VerdictSettings, +} + +#[derive(Deserialize, Serialize)] +struct VerdictSettings { + id: String, + thresholds: Value, + #[serde(rename = "defaultWrapUpMode")] + default_wrap_up_mode: String, + #[serde(rename = "wrapUpThresholdMinutes")] + wrap_up_threshold_minutes: i64, + #[serde(rename = "updatedAt")] + updated_at: String, +} + 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?; + let data: VerdictSettingsResponse = client.execute_typed(VERDICT_SETTINGS_QUERY, None).await?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["verdictSettings"])? - ); + println!("{}", serde_json::to_string_pretty(&data.verdict_settings)?); return Ok(()); } - let settings = &data["verdictSettings"]; + let settings = data.verdict_settings; println!(); println!(" {}", format::styled_bold("Verdict settings")); println!(); + println!(" {} {}", format::styled_dimmed("ID:"), settings.id); println!( " {} {}", - format::styled_dimmed("ID:"), - settings["id"].as_str().unwrap_or("-") + format::styled_dimmed("Default wrap-up mode:"), + settings.default_wrap_up_mode ); - println!(" {}", format::styled_dimmed("Mode thresholds:")); println!( - "{}", - serde_json::to_string_pretty(&settings["modeThresholds"])? + " {} {} min", + format::styled_dimmed("Wrap-up threshold:"), + settings.wrap_up_threshold_minutes ); + println!(" {}", format::styled_dimmed("Thresholds:")); + println!("{}", serde_json::to_string_pretty(&settings.thresholds)?); 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")?; +pub async fn set( + api_url: &str, + thresholds: Option<&str>, + default_wrap_up_mode: Option<&str>, + wrap_up_threshold_minutes: Option, + json: bool, +) -> Result<()> { + if thresholds.is_none() && default_wrap_up_mode.is_none() && wrap_up_threshold_minutes.is_none() + { + bail!("No updates provided. Pass at least one of --thresholds, --default-wrap-up-mode, or --wrap-up-threshold-minutes."); + } + + let parsed_thresholds: Option = thresholds + .map(|raw| serde_json::from_str(raw).context("thresholds must be valid JSON")) + .transpose()?; let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client - .execute( + let data: UpdateVerdictSettingsResponse = client + .execute_typed( UPDATE_VERDICT_SETTINGS_MUTATION, - Some(serde_json::json!({ "modeThresholds": parsed })), + Some(serde_json::json!({ + "thresholds": parsed_thresholds, + "defaultWrapUpMode": default_wrap_up_mode.map(|v| v.to_uppercase()), + "wrapUpThresholdMinutes": wrap_up_threshold_minutes, + })), ) .await?; if json { println!( "{}", - serde_json::to_string_pretty(&data["updateVerdictSettings"])? + serde_json::to_string_pretty(&data.update_verdict_settings)? ); return Ok(()); } @@ -85,7 +164,7 @@ pub async fn set(api_url: &str, mode_thresholds: &str, json: bool) -> Result<()> println!(); println!( "{}", - serde_json::to_string_pretty(&data["updateVerdictSettings"]["modeThresholds"])? + serde_json::to_string_pretty(&data.update_verdict_settings)? ); println!(); Ok(()) diff --git a/src/commands/windows.rs b/src/commands/windows.rs index cc57db2..f0c410d 100644 --- a/src/commands/windows.rs +++ b/src/commands/windows.rs @@ -1,8 +1,10 @@ -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::{format_days, DaysField}; use crate::format; const WINDOWS_QUERY: &str = r#" @@ -101,34 +103,68 @@ pub struct WindowInputArgs { pub status_text: Option, } +#[derive(Deserialize)] +struct WindowsResponse { + #[serde(rename = "reachabilityWindows")] + reachability_windows: Vec, +} + +#[derive(Deserialize)] +struct WindowMutationResponse { + #[serde(rename = "createReachabilityWindow")] + create_reachability_window: Option, + #[serde(rename = "updateReachabilityWindow")] + update_reachability_window: Option, + #[serde(rename = "deleteReachabilityWindow")] + delete_reachability_window: Option, +} + +#[derive(Deserialize, Serialize)] +struct ReachabilityWindow { + id: String, + label: String, + mode: String, + days: Option, + #[serde(rename = "startTime")] + start_time: Option, + #[serde(rename = "endTime")] + end_time: Option, + #[serde(rename = "alertsPolicy")] + alerts_policy: Option, + #[serde(rename = "autoActivate")] + auto_activate: Option, + priority: Option, + status: Option, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "statusText")] + 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?; + let data: WindowsResponse = client.execute_typed(WINDOWS_QUERY, None).await?; if json { println!( "{}", - serde_json::to_string_pretty(&data["reachabilityWindows"])? + serde_json::to_string_pretty(&data.reachability_windows)? ); 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() { + if data.reachability_windows.is_empty() { println!(" {}", format::styled_dimmed("No windows configured")); println!(); return Ok(()); } - for window in windows { + for window in &data.reachability_windows { print_window(window); } @@ -143,22 +179,22 @@ pub async fn create(api_url: &str, args: WindowInputArgs, json: bool) -> Result< let input = build_input(args); let variables = serde_json::json!({ "input": input }); - let data = client - .execute(CREATE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(CREATE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .create_reachability_window + .ok_or_else(|| anyhow!("Missing createReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["createReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); return Ok(()); } println!(); println!(" {} Window created", format::styled_green_bold("✓")); println!(); - print_window(&data["createReachabilityWindow"]); + print_window(&window); Ok(()) } @@ -172,22 +208,22 @@ pub async fn update(api_url: &str, id: &str, args: WindowInputArgs, json: bool) let input = build_input(args); let variables = serde_json::json!({ "id": id, "input": input }); - let data = client - .execute(UPDATE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(UPDATE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .update_reachability_window + .ok_or_else(|| anyhow!("Missing updateReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["updateReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); return Ok(()); } println!(); println!(" {} Window updated", format::styled_green_bold("✓")); println!(); - print_window(&data["updateReachabilityWindow"]); + print_window(&window); Ok(()) } @@ -196,26 +232,23 @@ pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { let client = GraphQLClient::new(api_url, &token); let variables = serde_json::json!({ "id": id }); - let data = client - .execute(DELETE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(DELETE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .delete_reachability_window + .ok_or_else(|| anyhow!("Missing deleteReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["deleteReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); 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) + format::styled_bold(&window.label) ); println!( " {} {}", @@ -264,7 +297,7 @@ fn build_input(args: WindowInputArgs) -> Value { input["mode"] = serde_json::json!(normalize_mode(&mode)); } if let Some(days) = args.days { - input["days"] = serde_json::json!(days); + input["days"] = serde_json::json!(normalize_days_input(&days)); } if let Some(start) = args.start { input["startTime"] = serde_json::json!(start); @@ -305,30 +338,82 @@ 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(""); +fn normalize_days_input(input: &str) -> Vec { + let normalized = input.trim(); + + if normalized.contains('-') { + let parts: Vec<&str> = normalized.split('-').collect(); + if parts.len() == 2 { + let start = normalize_day(parts[0]); + let end = normalize_day(parts[1]); + let ordered = [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY", + ]; + if let (Some(start_idx), Some(end_idx)) = ( + ordered.iter().position(|d| d == &start), + ordered.iter().position(|d| d == &end), + ) { + return if start_idx <= end_idx { + ordered[start_idx..=end_idx] + .iter() + .map(|d| d.to_string()) + .collect() + } else { + ordered[start_idx..] + .iter() + .chain(ordered[..=end_idx].iter()) + .map(|d| d.to_string()) + .collect() + }; + } + } + } + + normalized + .split(',') + .map(normalize_day) + .collect::>() +} + +fn normalize_day(day: &str) -> String { + match day.trim().to_lowercase().as_str() { + "mon" | "monday" => "MONDAY".to_string(), + "tue" | "tues" | "tuesday" => "TUESDAY".to_string(), + "wed" | "wednesday" => "WEDNESDAY".to_string(), + "thu" | "thur" | "thurs" | "thursday" => "THURSDAY".to_string(), + "fri" | "friday" => "FRIDAY".to_string(), + "sat" | "saturday" => "SATURDAY".to_string(), + "sun" | "sunday" => "SUNDAY".to_string(), + other => other.replace('-', "_").to_uppercase(), + } +} + +fn print_window(window: &ReachabilityWindow) { + let mode = window.mode.to_uppercase(); + let days = format_days(window.days.as_ref()); + let start = window.start_time.clone().unwrap_or_else(|| "-".to_string()); + let end = window.end_time.clone().unwrap_or_else(|| "-".to_string()); + let policy = window + .alerts_policy + .clone() + .unwrap_or_else(|| "-".to_string()); println!( " {} {} ({})", format::styled_dimmed("•"), - format::styled_bold(label), + format::styled_bold(&window.label), format::color_mode(&mode) ); println!( " {} {} {}-{}", format::styled_dimmed("Window:"), - days, + &days, start, end ); @@ -340,24 +425,26 @@ fn print_window(window: &Value) { println!( " {} {} {} {} {} {}", format::styled_dimmed("Priority:"), - priority, + window.priority.unwrap_or_default(), format::styled_dimmed("Auto:"), - auto_activate, + window.auto_activate.unwrap_or(false), format::styled_dimmed("Status:"), - status + window.status.unwrap_or(false) ); - if !emoji.is_empty() || !status_text.is_empty() { + if !window.status_emoji.as_deref().unwrap_or("").is_empty() + || !window.status_text.as_deref().unwrap_or("").is_empty() + { println!( " {} {} {}", format::styled_dimmed("Message:"), - emoji, - status_text + window.status_emoji.clone().unwrap_or_default(), + window.status_text.clone().unwrap_or_default() ); } println!( " {} {}", format::styled_dimmed("ID:"), - format::styled_dimmed(id) + format::styled_dimmed(&window.id) ); println!(); } @@ -416,4 +503,19 @@ mod tests { ..WindowInputArgs::default() })); } + + #[test] + fn normalize_days_input_supports_ranges_and_lists() { + assert_eq!(normalize_days_input("Mon-Fri").len(), 5); + assert_eq!( + normalize_days_input("Mon,Wed,Fri"), + vec!["MONDAY", "WEDNESDAY", "FRIDAY"] + ); + } + + #[test] + fn format_days_reads_array_shape() { + let value = DaysField::List(vec!["MONDAY".to_string(), "TUESDAY".to_string()]); + assert_eq!(format_days(Some(&value)), "monday,tuesday"); + } } diff --git a/src/contract/availability.rs b/src/contract/availability.rs new file mode 100644 index 0000000..1fa0aeb --- /dev/null +++ b/src/contract/availability.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct AvailabilityResolution { + #[serde(rename = "inReachableHours")] + pub in_reachable_hours: Option, + #[serde(rename = "nextTransitionAt")] + pub next_transition_at: Option, + #[serde(rename = "activeWindow")] + pub active_window: Option, + #[serde(rename = "nextWindow")] + pub next_window: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct AvailabilityWindow { + pub id: Option, + pub label: Option, + pub mode: Option, + #[serde(rename = "startTime")] + pub start_time: Option, + #[serde(rename = "endTime")] + pub end_time: Option, + pub days: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum DaysField { + List(Vec), + Single(String), +} + +pub fn format_days(days: Option<&DaysField>) -> String { + match days { + Some(DaysField::List(day_list)) if !day_list.is_empty() => day_list + .iter() + .map(|d| d.to_lowercase()) + .collect::>() + .join(","), + Some(DaysField::Single(day)) => day.clone(), + _ => "-".to_string(), + } +} diff --git a/src/contract/mod.rs b/src/contract/mod.rs new file mode 100644 index 0000000..faca698 --- /dev/null +++ b/src/contract/mod.rs @@ -0,0 +1 @@ +pub mod availability; diff --git a/src/main.rs b/src/main.rs index 2ad2275..9fd1b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod client; mod commands; mod config; +mod contract; mod format; mod telemetry; @@ -52,6 +53,18 @@ enum Commands { action: Option, }, + /// Manage delegation grants + Grants { + #[command(subcommand)] + action: Option, + }, + + /// Manage temporary availability overrides + Override { + #[command(subcommand)] + action: Option, + }, + /// Apply a preset by name or ID Preset { /// Preset name or ID @@ -334,46 +347,93 @@ enum WindowAction { enum PresetsAction { /// List configured presets List, +} - /// Create a preset - Create { +#[derive(Subcommand)] +enum GrantsAction { + /// List active grants + ListActive, + + /// List grants with optional filters + List { #[arg(long)] - name: String, + active: Option, + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: Option, #[arg(long)] - alerts: Option, + session_id: Option, #[arg(long)] - presence: Option, + workspace_ref: Option, #[arg(long)] - duration: Option, + agent_id: Option, #[arg(long)] - status: Option, + source: Option, + }, + + /// Create a grant + Create { + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: String, #[arg(long)] - status_emoji: Option, + session_id: Option, #[arg(long)] - status_text: Option, + workspace_ref: Option, + #[arg(long)] + agent_id: Option, + #[arg(long, value_delimiter = ',')] + permissions: Vec, + #[arg(long)] + duration_minutes: Option, + #[arg(long)] + expires_at: Option, + #[arg(long)] + source: Option, }, - /// Update a preset by id - Update { - id: String, + /// Revoke one grant by id + Revoke { id: String }, + + /// Revoke many grants with optional filters + RevokeMany { #[arg(long)] - name: Option, + active: Option, + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: Option, #[arg(long)] - alerts: Option, + session_id: Option, #[arg(long)] - presence: Option, + workspace_ref: Option, #[arg(long)] - duration: Option, + agent_id: Option, #[arg(long)] - status: Option, + source: Option, + }, +} + +#[derive(Subcommand)] +enum OverrideAction { + /// Get active override + Get, + + /// Set a temporary override + Set { + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: String, #[arg(long)] - status_emoji: Option, + duration_minutes: Option, #[arg(long)] - status_text: Option, + expires_at: Option, + #[arg(long)] + reason: Option, }, - /// Delete a preset by id - Delete { id: String }, + /// Clear active override (or specific id) + Clear { + #[arg(long)] + id: Option, + #[arg(long)] + reason: Option, + }, } #[derive(Subcommand)] @@ -410,11 +470,19 @@ enum VerdictSettingsAction { /// Show current verdict settings Get, - /// Update mode thresholds JSON + /// Update verdict settings Set { - /// JSON object for mode thresholds + /// JSON object for thresholds #[arg(long)] - mode_thresholds: String, + thresholds: Option, + + /// Default delivery mode near attention deadline (auto, wrap_up, full_depth) + #[arg(long, value_parser = ["auto", "wrap_up", "full_depth"])] + default_wrap_up_mode: Option, + + /// Minutes before attention deadline where wrap-up behavior activates + #[arg(long)] + wrap_up_threshold_minutes: Option, }, } @@ -591,58 +659,105 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { }, 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::Grants { action } => match action { + None | Some(GrantsAction::ListActive) => { + commands::grants::list_active(&api_url, json).await + } + Some(GrantsAction::List { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, }) => { - commands::presets::create( + commands::grants::list( &api_url, - commands::presets::PresetInputArgs { - name: Some(name), - alerts, - presence, - duration, - status, - status_emoji, - status_text, + commands::grants::GrantsFilterArgs { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, }, json, ) .await } - Some(PresetsAction::Update { - id, - name, - alerts, - presence, - duration, - status, - status_emoji, - status_text, + Some(GrantsAction::Create { + scope, + session_id, + workspace_ref, + agent_id, + permissions, + duration_minutes, + expires_at, + source, }) => { - commands::presets::update( + commands::grants::create( &api_url, - &id, - commands::presets::PresetInputArgs { - name, - alerts, - presence, - duration, - status, - status_emoji, - status_text, + commands::grants::CreateGrantArgs { + scope: Some(scope), + session_id, + workspace_ref, + agent_id, + permissions, + duration_minutes, + expires_at, + source, }, json, ) .await } - Some(PresetsAction::Delete { id }) => { - commands::presets::delete(&api_url, &id, json).await + Some(GrantsAction::Revoke { id }) => { + commands::grants::revoke(&api_url, &id, json).await + } + Some(GrantsAction::RevokeMany { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, + }) => { + commands::grants::revoke_many( + &api_url, + commands::grants::GrantsFilterArgs { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, + }, + json, + ) + .await + } + }, + Commands::Override { action } => match action { + None | Some(OverrideAction::Get) => commands::override_cmd::get(&api_url, json).await, + Some(OverrideAction::Set { + mode, + duration_minutes, + expires_at, + reason, + }) => { + commands::override_cmd::set( + &api_url, + Some(mode), + duration_minutes, + expires_at, + reason, + json, + ) + .await + } + Some(OverrideAction::Clear { id, reason }) => { + commands::override_cmd::clear(&api_url, id, reason, json).await } }, Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, @@ -674,8 +789,19 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { 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 + Some(VerdictSettingsAction::Set { + thresholds, + default_wrap_up_mode, + wrap_up_threshold_minutes, + }) => { + commands::verdict_settings::set( + &api_url, + thresholds.as_deref(), + default_wrap_up_mode.as_deref(), + wrap_up_threshold_minutes, + json, + ) + .await } }, Commands::Proposals { latest, verdict } => { @@ -769,6 +895,8 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Availability { .. } => "availability", Commands::Windows { .. } => "windows", Commands::Presets { .. } => "presets", + Commands::Grants { .. } => "grants", + Commands::Override { .. } => "override", Commands::Preset { .. } => "preset", Commands::Digest { .. } => "digest", Commands::Autoresponder { .. } => "autoresponder", diff --git a/tests/backend_parity.rs b/tests/backend_parity.rs new file mode 100644 index 0000000..110731f --- /dev/null +++ b/tests/backend_parity.rs @@ -0,0 +1,536 @@ +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; +use wiremock::matchers::{body_string_contains, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn prepare_auth_dir() -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("headsdown"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("credentials.json"), + r#"{"apiKey":"hd_test_token","createdAt":"2026-04-21T00:00:00Z"}"#, + ) + .unwrap(); + dir +} + +fn run_json(args: &[&str], auth_dir: &TempDir) -> Value { + let assert = Command::cargo_bin("hd") + .unwrap() + .args(args) + .env("XDG_CONFIG_HOME", auth_dir.path()) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + serde_json::from_str(&stdout).unwrap() +} + +#[tokio::test] +async fn verdict_json_matches_latest_submit_proposal_shape() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation SubmitProposal")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "submitProposal": { + "decision": "APPROVED", + "reason": "Looks good", + "proposalId": "prop_123", + "evaluatedAt": "2026-04-21T16:00:00Z", + "wrapUpGuidance": { + "active": false, + "deadlineAt": null, + "remainingMinutes": null, + "profile": "NORMAL", + "source": "INACTIVE", + "reason": "Outside threshold", + "hints": [], + "thresholdMinutes": 30, + "selectedMode": "AUTO" + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict", + "refactor auth module", + "--files", + "5", + "--minutes", + "30", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["decision"], "APPROVED"); + assert_eq!(json["proposalId"], "prop_123"); + assert_eq!(json["wrapUpGuidance"]["selectedMode"], "AUTO"); +} + +#[tokio::test] +async fn verdict_settings_get_json_matches_thresholds_shape() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query")) + .and(body_string_contains("verdictSettings")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "verdictSettings": { + "id": "vs_1", + "thresholds": { + "online": {"maxFiles": 10, "maxEstimatedMinutes": 120}, + "busy": {"maxFiles": 3, "maxEstimatedMinutes": 45}, + "limited": {"maxFiles": 2, "maxEstimatedMinutes": 30}, + "offline": {"maxFiles": 0, "maxEstimatedMinutes": 0} + }, + "defaultWrapUpMode": "AUTO", + "wrapUpThresholdMinutes": 30, + "updatedAt": "2026-04-21T16:00:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict-settings", + "get", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["id"], "vs_1"); + assert_eq!(json["thresholds"]["busy"]["maxFiles"], 3); + assert_eq!(json["defaultWrapUpMode"], "AUTO"); +} + +#[tokio::test] +async fn verdict_settings_set_sends_new_shape_and_returns_payload() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation UpdateVerdictSettings")) + .and(body_string_contains("\"thresholds\"")) + .and(body_string_contains("\"defaultWrapUpMode\":\"WRAP_UP\"")) + .and(body_string_contains("\"wrapUpThresholdMinutes\":25")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "updateVerdictSettings": { + "id": "vs_2", + "thresholds": { + "online": {"maxFiles": 8, "maxEstimatedMinutes": 90}, + "busy": {"maxFiles": 3, "maxEstimatedMinutes": 30}, + "limited": {"maxFiles": 2, "maxEstimatedMinutes": 20}, + "offline": {"maxFiles": 0, "maxEstimatedMinutes": 0} + }, + "defaultWrapUpMode": "WRAP_UP", + "wrapUpThresholdMinutes": 25, + "updatedAt": "2026-04-21T16:05:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let thresholds = r#"{"online":{"maxFiles":8,"maxEstimatedMinutes":90}}"#; + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict-settings", + "set", + "--thresholds", + thresholds, + "--default-wrap-up-mode", + "wrap_up", + "--wrap-up-threshold-minutes", + "25", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["id"], "vs_2"); + assert_eq!(json["defaultWrapUpMode"], "WRAP_UP"); + assert_eq!(json["wrapUpThresholdMinutes"], 25); +} + +#[tokio::test] +async fn grants_list_active_json_works() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query ActiveDelegationGrants")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "activeDelegationGrants": [ + { + "id": "grant_1", + "scope": "WORKSPACE", + "sessionId": null, + "workspaceRef": "/repo", + "agentId": "pi-agent", + "permissions": ["PRESET_APPLY"], + "source": "pi", + "expiresAt": "2026-04-21T20:00:00Z", + "revokedAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T15:00:00Z" + } + ] + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "list-active", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json.as_array().unwrap().len(), 1); + assert_eq!(json[0]["id"], "grant_1"); +} + +#[tokio::test] +async fn grants_create_and_revoke_many_mutations_work() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CreateDelegationGrant")) + .and(body_string_contains("\"scope\":\"WORKSPACE\"")) + .and(body_string_contains( + "\"permissions\":[\"PRESET_APPLY\",\"AVAILABILITY_OVERRIDE_CREATE\"]", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "createDelegationGrant": { + "id": "grant_2", + "scope": "WORKSPACE", + "sessionId": null, + "workspaceRef": "/repo", + "agentId": null, + "permissions": ["PRESET_APPLY", "AVAILABILITY_OVERRIDE_CREATE"], + "source": "hd", + "expiresAt": "2026-04-21T20:00:00Z", + "revokedAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:10:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation RevokeDelegationGrants")) + .and(body_string_contains("\"scope\":\"WORKSPACE\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "revokeDelegationGrants": { + "revokedCount": 2 + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let created = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "create", + "--scope", + "workspace", + "--workspace-ref", + "/repo", + "--permissions", + "preset_apply,availability_override_create", + "--json", + ], + &auth_dir, + ); + assert_eq!(created["id"], "grant_2"); + + let revoked = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "revoke-many", + "--scope", + "workspace", + "--json", + ], + &auth_dir, + ); + assert_eq!(revoked["revokedCount"], 2); +} + +#[tokio::test] +async fn override_get_set_clear_json_work() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query ActiveAvailabilityOverride")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "activeAvailabilityOverride": { + "id": "ovr_1", + "mode": "BUSY", + "reason": "focus", + "source": "pi", + "expiresAt": "2026-04-21T18:00:00Z", + "cancelledAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:00:00Z", + "updatedAt": "2026-04-21T16:00:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CreateAvailabilityOverride")) + .and(body_string_contains("\"mode\":\"BUSY\"")) + .and(body_string_contains("\"durationMinutes\":30")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "createAvailabilityOverride": { + "id": "ovr_2", + "mode": "BUSY", + "reason": "focus", + "source": "hd", + "expiresAt": "2026-04-21T17:00:00Z", + "cancelledAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:20:00Z", + "updatedAt": "2026-04-21T16:20:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CancelAvailabilityOverride")) + .and(body_string_contains("\"id\":\"ovr_2\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "cancelAvailabilityOverride": { + "id": "ovr_2", + "mode": "BUSY", + "reason": "done", + "source": "hd", + "expiresAt": "2026-04-21T17:00:00Z", + "cancelledAt": "2026-04-21T16:30:00Z", + "expiredAt": null, + "insertedAt": "2026-04-21T16:20:00Z", + "updatedAt": "2026-04-21T16:30:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + + let current = run_json( + &["--api-url", &server.uri(), "override", "get", "--json"], + &auth_dir, + ); + assert_eq!(current["id"], "ovr_1"); + + let created = run_json( + &[ + "--api-url", + &server.uri(), + "override", + "set", + "--mode", + "busy", + "--duration-minutes", + "30", + "--reason", + "focus", + "--json", + ], + &auth_dir, + ); + assert_eq!(created["id"], "ovr_2"); + + let cleared = run_json( + &[ + "--api-url", + &server.uri(), + "override", + "clear", + "--id", + "ovr_2", + "--reason", + "done", + "--json", + ], + &auth_dir, + ); + assert_eq!(cleared["id"], "ovr_2"); + assert_eq!(cleared["cancelledAt"], "2026-04-21T16:30:00Z"); +} + +#[tokio::test] +async fn migrated_commands_fail_fast_on_shape_mismatch() { + struct Case { + operation_hint: &'static str, + args: Vec<&'static str>, + response_data: Value, + } + + let cases = vec![ + Case { + operation_hint: "activeContract", + args: vec!["status", "--json"], + response_data: serde_json::json!({ + "activeContract": {"mode": 123, "statusText": null, "statusEmoji": null, "expiresAt": null, "lock": false}, + "availability": null, + "profile": null + }), + }, + Case { + operation_hint: "availability", + args: vec!["availability", "--json"], + response_data: serde_json::json!({ + "availability": {"inReachableHours": "yes", "nextTransitionAt": null, "activeWindow": null, "nextWindow": null} + }), + }, + Case { + operation_hint: "reachabilityWindows", + args: vec!["windows", "list", "--json"], + response_data: serde_json::json!({ + "reachabilityWindows": [{"id":"w1","label":"Focus","mode":7,"days":["MONDAY"],"startTime":"09:00:00","endTime":"17:00:00","alertsPolicy":"OFF","autoActivate":true,"priority":1,"status":false,"statusEmoji":null,"statusText":null,"snooze":false}] + }), + }, + Case { + operation_hint: "presets", + args: vec!["presets", "list", "--json"], + response_data: serde_json::json!({ + "presets": [{"id":"p1","name":99,"statusEmoji":null,"statusText":"Deep work","duration":30}] + }), + }, + Case { + operation_hint: "activeDelegationGrants", + args: vec!["grants", "list-active", "--json"], + response_data: serde_json::json!({ + "activeDelegationGrants": [{"id":"g1","scope":42,"expiresAt":null,"permissions":[]}] + }), + }, + Case { + operation_hint: "activeAvailabilityOverride", + args: vec!["override", "get", "--json"], + response_data: serde_json::json!({ + "activeAvailabilityOverride": {"id":"ovr_1","mode":1,"expiresAt":null,"cancelledAt":null} + }), + }, + Case { + operation_hint: "submitProposal", + args: vec![ + "verdict", + "refactor auth", + "--files", + "3", + "--minutes", + "20", + "--json", + ], + response_data: serde_json::json!({ + "submitProposal": {"decision":1,"reason":"ok","proposalId":"prop_1","wrapUpGuidance":null} + }), + }, + Case { + operation_hint: "verdictSettings", + args: vec!["verdict-settings", "get", "--json"], + response_data: serde_json::json!({ + "verdictSettings": {"id":"vs_1","thresholds":{},"defaultWrapUpMode":1,"wrapUpThresholdMinutes":30,"updatedAt":"2026-04-21T16:00:00Z"} + }), + }, + ]; + + for case in cases { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains(case.operation_hint)) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data": case.response_data})), + ) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let mut args: Vec = vec!["--api-url".to_string(), server.uri()]; + args.extend(case.args.iter().map(|value| value.to_string())); + + let assert = Command::cargo_bin("hd") + .unwrap() + .args(&args) + .env("XDG_CONFIG_HOME", auth_dir.path()) + .assert() + .failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("Failed to decode API response shape"), + "expected decode-shape failure for operation hint {}, got: {}", + case.operation_hint, + stderr + ); + } +} diff --git a/tests/graphql_contract.rs b/tests/graphql_contract.rs index 06ab071..cd33da6 100644 --- a/tests/graphql_contract.rs +++ b/tests/graphql_contract.rs @@ -17,3 +17,34 @@ fn command_queries_use_availability_root_field() { "availability query should reference availability root field" ); } + +#[test] +fn verdict_query_matches_latest_submit_proposal_shape() { + let src = include_str!("../src/commands/verdict.rs"); + assert!(src.contains("decision")); + assert!(src.contains("reason")); + assert!(src.contains("proposalId")); + assert!(src.contains("wrapUpGuidance")); + assert!(!src.contains("policyStatus")); +} + +#[test] +fn verdict_settings_query_uses_thresholds_shape() { + let src = include_str!("../src/commands/verdict_settings.rs"); + assert!(src.contains("thresholds")); + assert!(src.contains("defaultWrapUpMode")); + assert!(src.contains("wrapUpThresholdMinutes")); + assert!(!src.contains("modeThresholds")); +} + +#[test] +fn windows_and_availability_handle_days_arrays() { + let windows_src = include_str!("../src/commands/windows.rs"); + let availability_src = include_str!("../src/commands/availability.rs"); + let contract_src = include_str!("../src/contract/availability.rs"); + + assert!(windows_src.contains("normalize_days_input")); + assert!(windows_src.contains("DaysField")); + assert!(availability_src.contains("format_days(")); + assert!(contract_src.contains("enum DaysField")); +} diff --git a/tests/integration.rs b/tests/integration.rs index fd925ad..0490368 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -64,6 +64,8 @@ fn subcommand_help_works() { "limited", "verdict", "presets", + "grants", + "override", "preset", "watch", "doctor", @@ -103,11 +105,38 @@ fn windows_subcommand_help_works() { #[test] fn presets_subcommand_help_works() { + Command::cargo_bin("hd") + .unwrap() + .args(["presets", "list", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); +} + +#[test] +fn grants_subcommand_help_works() { + for cmd in &[ + ["grants", "list-active"], + ["grants", "list"], + ["grants", "create"], + ["grants", "revoke"], + ["grants", "revoke-many"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn override_subcommand_help_works() { for cmd in &[ - ["presets", "list"], - ["presets", "create"], - ["presets", "update"], - ["presets", "delete"], + ["override", "get"], + ["override", "set"], + ["override", "clear"], ] { Command::cargo_bin("hd") .unwrap()