Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ hd auth
hd status
hd availability
hd windows
hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00
hd presets create --name "Deep Focus" --alerts do_not_disturb --presence on_keys --duration 90
hd digest list --latest 10
hd autoresponder get

# Set yourself to busy for 2 hours
hd busy 2h
Expand All @@ -57,15 +61,29 @@ hd watch
| `hd auth` | Authenticate via Device Flow (browser-based) |
| `hd status` | Show your current availability |
| `hd availability [--at <rfc3339>]` | Show availability resolution and next transition |
| `hd windows` | List configured reachability windows |
| `hd windows [list]` | List configured reachability windows |
| `hd windows create ...` | Create a reachability window |
| `hd windows update <id> ...` | Update a reachability window |
| `hd windows delete <id>` | Delete a reachability window |
| `hd presets [list]` | List available presets |
| `hd presets create ...` | Create a preset |
| `hd presets update <id> ...` | Update a preset |
| `hd presets delete <id>` | Delete a preset |
| `hd preset "name"` | Activate a preset |
| `hd digest [list] [--latest N]` | List digest summaries |
| `hd digest dismiss <id>` | 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 '<json>'` | Update verdict mode thresholds |
| `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals |
| `hd interrupt <handle>` | Evaluate if an interrupt is allowed |
| `hd whoami` | Show your authenticated identity |
| `hd busy [duration]` | Set mode to busy |
| `hd online` | Set mode to online |
| `hd offline` | Set mode to offline |
| `hd limited [duration]` | Set mode to limited |
| `hd verdict "desc"` | Submit a task proposal and get a verdict |
| `hd presets` | List available presets |
| `hd preset "name"` | Activate a preset |
| `hd watch` | Live-updating status dashboard |
| `hd doctor` | Check CLI health and connectivity |
| `hd update` | Self-update to the latest version |
Expand Down
122 changes: 122 additions & 0 deletions src/commands/autoresponder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
use anyhow::{bail, Result};

use crate::auth;
use crate::client::GraphQLClient;
use crate::format;

const AUTO_RESPONDER_QUERY: &str = r#"
query {
autoResponderSettings {
id
busyText
limitedText
offlineText
updatedAt
}
}
"#;

const UPDATE_AUTO_RESPONDER_MUTATION: &str = r#"
mutation UpdateAutoResponderSettings($busyText: String, $limitedText: String, $offlineText: String) {
updateAutoResponderSettings(busyText: $busyText, limitedText: $limitedText, offlineText: $offlineText) {
id
busyText
limitedText
offlineText
updatedAt
}
}
"#;

pub async fn get(api_url: &str, json: bool) -> Result<()> {
let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);
let data = client.execute(AUTO_RESPONDER_QUERY, None).await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&data["autoResponderSettings"])?
);
return Ok(());
}

let settings = &data["autoResponderSettings"];
println!();
println!(" {}", format::styled_bold("Auto-responder"));
println!();
println!(
" {} {}",
format::styled_dimmed("Busy:"),
settings["busyText"].as_str().unwrap_or("-")
);
println!(
" {} {}",
format::styled_dimmed("Limited:"),
settings["limitedText"].as_str().unwrap_or("-")
);
println!(
" {} {}",
format::styled_dimmed("Offline:"),
settings["offlineText"].as_str().unwrap_or("-")
);
println!();
Ok(())
}

pub async fn set(
api_url: &str,
busy_text: Option<String>,
limited_text: Option<String>,
offline_text: Option<String>,
json: bool,
) -> Result<()> {
if busy_text.is_none() && limited_text.is_none() && offline_text.is_none() {
bail!("No updates provided. Pass at least one of --busy-text, --limited-text, or --offline-text.");
}

let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);

let variables = serde_json::json!({
"busyText": busy_text,
"limitedText": limited_text,
"offlineText": offline_text,
});
let data = client
.execute(UPDATE_AUTO_RESPONDER_MUTATION, Some(variables))
.await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&data["updateAutoResponderSettings"])?
);
return Ok(());
}

println!();
println!(
" {} Auto-responder updated",
format::styled_green_bold("✓")
);
println!();
let settings = &data["updateAutoResponderSettings"];
println!(
" {} {}",
format::styled_dimmed("Busy:"),
settings["busyText"].as_str().unwrap_or("-")
);
println!(
" {} {}",
format::styled_dimmed("Limited:"),
settings["limitedText"].as_str().unwrap_or("-")
);
println!(
" {} {}",
format::styled_dimmed("Offline:"),
settings["offlineText"].as_str().unwrap_or("-")
);
println!();
Ok(())
}
148 changes: 148 additions & 0 deletions src/commands/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use anyhow::Result;

use crate::auth;
use crate::client::GraphQLClient;
use crate::format;

const DIGEST_QUERY: &str = r#"
query DigestSummaries($latest: Int) {
digestSummaries(latest: $latest) {
id
action
actorLabel
actorRef
channelRef
sourceType
entryCount
firstEventAt
lastEventAt
events {
description
insertedAt
}
}
}
"#;

const DISMISS_DIGEST_MUTATION: &str = r#"
mutation DismissDigestEntry($id: ID!) {
dismissDigestEntry(id: $id) {
id
action
actorLabel
entryCount
sourceType
}
}
"#;

pub async fn list(api_url: &str, latest: Option<i32>, json: bool) -> Result<()> {
let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);

let variables = serde_json::json!({ "latest": latest });
let data = client.execute(DIGEST_QUERY, Some(variables)).await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&data["digestSummaries"])?
);
return Ok(());
}

let entries = data["digestSummaries"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("No digest summaries found"))?;

println!();
println!(" {}", format::styled_bold("Digest Summaries"));
println!();

if entries.is_empty() {
println!(" {}", format::styled_dimmed("No digest entries."));
println!();
return Ok(());
}

for entry in entries {
let id = entry["id"].as_str().unwrap_or("-");
let action = entry["action"].as_str().unwrap_or("-");
let actor = entry["actorLabel"]
.as_str()
.or_else(|| entry["actorRef"].as_str())
.unwrap_or("Unknown");
let count = entry["entryCount"].as_i64().unwrap_or_default();
let source = entry["sourceType"].as_str().unwrap_or("-");

println!(
" {} {} ({})",
format::styled_dimmed("•"),
format::styled_bold(actor),
action.to_lowercase()
);
println!(
" {} {} {} {}",
format::styled_dimmed("Entries:"),
count,
format::styled_dimmed("Source:"),
source.to_lowercase()
);
println!(
" {} {}",
format::styled_dimmed("ID:"),
format::styled_dimmed(id)
);

if let Some(events) = entry["events"].as_array() {
for event in events.iter().take(2) {
if let Some(description) = event["description"].as_str() {
println!(
" {} {}",
format::styled_dimmed("-"),
format::styled_dimmed(description)
);
}
}
}

println!();
}

Ok(())
}

pub async fn dismiss(api_url: &str, id: &str, json: bool) -> Result<()> {
let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);

let data = client
.execute(
DISMISS_DIGEST_MUTATION,
Some(serde_json::json!({ "id": id })),
)
.await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&data["dismissDigestEntry"])?
);
return Ok(());
}

let entry = &data["dismissDigestEntry"];
println!();
println!(
" {} Dismissed digest entry",
format::styled_green_bold("✓")
);
println!(
" {} {}",
format::styled_dimmed("ID:"),
format::styled_dimmed(entry["id"].as_str().unwrap_or(id))
);
println!();

Ok(())
}
61 changes: 61 additions & 0 deletions src/commands/interrupt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use anyhow::Result;

use crate::auth;
use crate::client::GraphQLClient;
use crate::format;

const EVALUATE_INTERRUPT_QUERY: &str = r#"
query EvaluateInterrupt($handle: String) {
evaluateInterrupt(handle: $handle) {
allowed
reason
autoResponse
}
}
"#;

pub async fn evaluate(api_url: &str, handle: &str, json: bool) -> Result<()> {
let token = auth::require_token()?;
let client = GraphQLClient::new(api_url, &token);

let data = client
.execute(
EVALUATE_INTERRUPT_QUERY,
Some(serde_json::json!({ "handle": handle })),
)
.await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&data["evaluateInterrupt"])?
);
return Ok(());
}

let result = &data["evaluateInterrupt"];
let allowed = result["allowed"].as_bool().unwrap_or(false);

println!();
println!(" {} {}", format::styled_dimmed("Handle:"), handle);
println!(
" {} {}",
format::styled_dimmed("Allowed:"),
if allowed {
format::styled_green_bold("yes")
} else {
format::styled_yellow_bold("no")
}
);
if let Some(reason) = result["reason"].as_str() {
println!(" {} {}", format::styled_dimmed("Reason:"), reason);
}
if let Some(response) = result["autoResponse"].as_str() {
if !response.is_empty() {
println!(" {} {}", format::styled_dimmed("Auto-response:"), response);
}
}
println!();

Ok(())
}
5 changes: 5 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
pub mod alias;
pub mod auth;
pub mod autoresponder;
pub mod availability;
pub mod calibration_cmd;
pub mod digest;
pub mod doctor;
pub mod hooks;
pub mod interrupt;
pub mod mode;
pub mod outcome;
pub mod presets;
pub mod proposals;
pub mod status;
pub mod telemetry_cmd;
pub mod update;
pub mod verdict;
pub mod verdict_settings;
pub mod watch;
pub mod whoami;
pub mod windows;
Loading
Loading