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
20 changes: 20 additions & 0 deletions .github/workflows/contract-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -62,22 +71,21 @@ hd watch
| `hd status` | Show your current availability |
| `hd availability [--at <rfc3339>]` | 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 <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 verdict-settings set --thresholds '<json>'` | Update verdict threshold settings |
| `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals |
| `hd interrupt <handle>` | 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 |
Expand Down Expand Up @@ -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

Expand Down
143 changes: 76 additions & 67 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<String>,
}

/// Returns the path to the config directory, respecting XDG_CONFIG_HOME.
fn config_dir() -> Result<PathBuf> {
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<PathBuf> {
Ok(config_dir()?.join("credentials"))
}

Ok(dir.join("credentials"))
fn json_credentials_path() -> Result<PathBuf> {
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<Option<String>> {
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::<JsonCredentials>(&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)
Expand All @@ -33,15 +61,32 @@ pub fn load_token() -> Result<Option<String>> {
}
}

/// 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;
Expand All @@ -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(())
Expand Down Expand Up @@ -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()));
});
}

Expand All @@ -142,33 +170,14 @@ 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]
fn store_token_sets_600_permissions() {
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)");
});
Expand Down
62 changes: 60 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +11,7 @@ pub struct GraphQLClient {
client: Client,
endpoint: String,
token: String,
actor_context: Option<String>,
}

#[derive(Serialize)]
Expand All @@ -35,13 +37,23 @@ 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))
.build()
.unwrap_or_default(),
endpoint: format!("{}/graphql", api_url),
token: token.to_string(),
actor_context: Some(actor_context.to_string()),
}
}

Expand Down Expand Up @@ -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<T: DeserializeOwned>(
&self,
query: &str,
variables: Option<Value>,
) -> Result<T> {
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<Value> {
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
Expand Down Expand Up @@ -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");
}
}
Loading
Loading