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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.8.0] - 2025-12-09

### Changed (BREAKING)

- `CommandHandler::handle()` now takes `ctx: CommandContext` instead of `terminal_info: TerminalInfo`
- Access terminal info via `ctx.terminal_info`

### Added

- **Environment variable passing**: Pass env vars from client to daemon per-command
- New `EnvVarFilter` for exact-match filtering of env var names
- New `CommandContext` struct bundling terminal info + env vars
- `DaemonClient::with_env_filter()` builder method
- **Terminal theme detection**: Detect dark/light mode via `terminal-colorsaurus`
- New `Theme` enum (`Dark`, `Light`)
- New `TerminalInfo.theme: Option<Theme>` field
- Uses 50ms timeout, returns `None` if detection fails

## [0.7.0] - 2025-12-03

### Changed (BREAKING)
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "daemon-cli"
version = "0.7.0"
version = "0.8.0"
edition = "2024"

[dependencies]
Expand All @@ -18,6 +18,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
parking_lot = "0.12"
terminal_size = "0.4"
supports-color = "3.0"
terminal-colorsaurus = "0.4"
interprocess = { version = "2.2", features = ["tokio"] }

[target.'cfg(unix)'.dependencies]
Expand Down
2 changes: 1 addition & 1 deletion examples/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ impl CommandHandler for CommandProcessor {
async fn handle(
&self,
command: &str,
_terminal_info: TerminalInfo,
_ctx: CommandContext,
mut output: impl AsyncWrite + Send + Unpin,
cancel_token: CancellationToken,
) -> Result<i32> {
Expand Down
2 changes: 1 addition & 1 deletion examples/concurrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl CommandHandler for TaskQueueHandler {
async fn handle(
&self,
command: &str,
_terminal_info: TerminalInfo,
_ctx: CommandContext,
mut output: impl AsyncWrite + Send + Unpin,
cancel_token: CancellationToken,
) -> Result<i32> {
Expand Down
54 changes: 47 additions & 7 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::StartupReason;
use crate::error_context::{ErrorContextBuffer, get_or_init_global_error_context};
use crate::process::{TerminateResult, kill_process, process_exists, terminate_process};
use crate::terminal::TerminalInfo;
use crate::transport::{SocketClient, SocketMessage, daemon_socket_exists, socket_path};
use crate::{CommandContext, EnvVarFilter, StartupReason};
use anyhow::{Result, bail};
use std::{fs, path::PathBuf, process::Stdio, time::Duration};
use tokio::{io::AsyncWriteExt, process::Command, time::sleep};
Expand Down Expand Up @@ -48,6 +48,8 @@ pub struct DaemonClient {
error_context: ErrorContextBuffer,
/// Enable automatic daemon restart on fatal connection errors (default: false)
auto_restart_on_error: bool,
/// Filter for which environment variables to pass to daemon
env_var_filter: EnvVarFilter,
}

impl DaemonClient {
Expand Down Expand Up @@ -204,6 +206,7 @@ impl DaemonClient {
build_timestamp,
error_context,
auto_restart_on_error: false,
env_var_filter: EnvVarFilter::none(),
})
}

Expand Down Expand Up @@ -452,10 +455,12 @@ impl DaemonClient {
)
.await?;

// Replace self with new client, preserving auto_restart setting
// Replace self with new client, preserving settings
let auto_restart = self.auto_restart_on_error;
let env_filter = std::mem::take(&mut self.env_var_filter);
*self = new_client;
self.auto_restart_on_error = auto_restart;
self.env_var_filter = env_filter;

Ok(())
}
Expand Down Expand Up @@ -516,6 +521,7 @@ impl DaemonClient {
build_timestamp,
error_context,
auto_restart_on_error: false,
env_var_filter: EnvVarFilter::none(),
})
}

Expand Down Expand Up @@ -547,6 +553,31 @@ impl DaemonClient {
self
}

/// Configure which environment variables to pass to the daemon.
///
/// By default, no environment variables are passed (backward compatible).
/// Use [`EnvVarFilter::with_names`] to specify exact variable names to include.
///
/// # Example
///
/// ```rust,no_run
/// use daemon_cli::prelude::*;
///
/// # tokio_test::block_on(async {
/// let mut client = DaemonClient::connect("/path/to/project")
/// .await?
/// .with_env_filter(EnvVarFilter::with_names(["MY_APP_DEBUG", "MY_APP_CONFIG"]));
///
/// // Commands will now include these env vars if they are set
/// client.execute_command("process file.txt".to_string()).await?;
/// # Ok::<(), anyhow::Error>(())
/// # });
/// ```
pub fn with_env_filter(mut self, filter: EnvVarFilter) -> Self {
self.env_var_filter = filter;
self
}

/// Check if an error indicates a fatal connection issue (daemon crash/hang).
///
/// Returns true for errors that suggest the daemon has crashed or become
Expand Down Expand Up @@ -618,12 +649,21 @@ impl DaemonClient {
"Detected terminal info"
);

// Send command with terminal info
// Filter environment variables based on configured names
let env_vars = self.env_var_filter.filter_current_env();
if !env_vars.is_empty() {
tracing::debug!(
env_var_count = env_vars.len(),
"Passing filtered environment variables"
);
}

// Build command context
let context = CommandContext::with_env(terminal_info, env_vars);

// Send command with context
self.socket_client
.send_message(&SocketMessage::Command {
command,
terminal_info,
})
.send_message(&SocketMessage::Command { command, context })
.await
.inspect_err(|_| {
self.error_context.dump_to_stderr();
Expand Down
131 changes: 119 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
//! async fn handle(
//! &self,
//! command: &str,
//! terminal_info: TerminalInfo,
//! ctx: CommandContext,
//! mut output: impl AsyncWrite + Send + Unpin,
//! cancel_token: CancellationToken,
//! ) -> Result<i32> {
Expand All @@ -62,7 +62,7 @@
//! # async fn handle(
//! # &self,
//! # command: &str,
//! # _terminal_info: TerminalInfo,
//! # _ctx: CommandContext,
//! # mut output: impl AsyncWrite + Send + Unpin,
//! # _cancel_token: CancellationToken,
//! # ) -> Result<i32> {
Expand Down Expand Up @@ -100,7 +100,8 @@

use anyhow::Result;
use async_trait::async_trait;
use std::{env, fs, str::FromStr, time::UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, env, fs, str::FromStr, time::UNIX_EPOCH};
use tokio::io::AsyncWrite;
use tokio_util::sync::CancellationToken;

Expand All @@ -114,7 +115,113 @@ mod transport;
pub use client::DaemonClient;
pub use error_context::ErrorContextBuffer;
pub use server::{DaemonHandle, DaemonServer};
pub use terminal::{ColorSupport, TerminalInfo};
pub use terminal::{ColorSupport, TerminalInfo, Theme};

/// Configuration for filtering which environment variables to pass from client to daemon.
///
/// By default, no environment variables are passed. Use [`EnvVarFilter::with_names`] to
/// specify exact variable names to include.
///
/// # Example
///
/// ```rust
/// use daemon_cli::EnvVarFilter;
///
/// // Pass specific env vars
/// let filter = EnvVarFilter::with_names(["MY_APP_DEBUG", "MY_APP_CONFIG"]);
///
/// // Or build incrementally
/// let filter = EnvVarFilter::none()
/// .include("MY_APP_DEBUG")
/// .include("MY_APP_CONFIG");
/// ```
#[derive(Debug, Clone, Default)]
pub struct EnvVarFilter {
names: Vec<String>,
}

impl EnvVarFilter {
/// Create a filter that passes no environment variables (default).
pub fn none() -> Self {
Self { names: vec![] }
}

/// Create a filter that passes env vars with the specified exact names.
pub fn with_names(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
names: names.into_iter().map(Into::into).collect(),
}
}

/// Include an env var name to pass.
pub fn include(mut self, name: impl Into<String>) -> Self {
self.names.push(name.into());
self
}

/// Filter environment variables from the provided source.
///
/// This is useful for testing or when you want to filter from
/// a custom set of variables rather than the current process env.
pub fn filter_from<K, V>(
&self,
env: impl IntoIterator<Item = (K, V)>,
) -> HashMap<String, String>
where
K: AsRef<str>,
V: Into<String>,
{
if self.names.is_empty() {
return HashMap::new();
}
env.into_iter()
.filter(|(k, _)| self.names.iter().any(|n| n == k.as_ref()))
.map(|(k, v)| (k.as_ref().to_string(), v.into()))
.collect()
}

/// Filter environment variables from the current process.
///
/// Returns a HashMap containing only the env vars whose names match
/// those configured in this filter.
pub fn filter_current_env(&self) -> HashMap<String, String> {
self.filter_from(std::env::vars())
}
}

/// Context information passed with each command execution.
///
/// This struct bundles metadata about the command execution environment,
/// including terminal information and environment variables. It is designed
/// for extensibility - new fields can be added in the future without breaking
/// the handler trait signature.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CommandContext {
/// Information about the client's terminal environment
pub terminal_info: TerminalInfo,
/// Environment variables passed from client (filtered by exact name match).
/// Empty by default for backward compatibility.
#[serde(default)]
pub env_vars: HashMap<String, String>,
}

impl CommandContext {
/// Create a new CommandContext with terminal info only (no env vars).
pub fn new(terminal_info: TerminalInfo) -> Self {
Self {
terminal_info,
env_vars: HashMap::new(),
}
}

/// Create a CommandContext with terminal info and environment variables.
pub fn with_env(terminal_info: TerminalInfo, env_vars: HashMap<String, String>) -> Self {
Self {
terminal_info,
env_vars,
}
}
}

/// Reason why daemon was started.
///
Expand Down Expand Up @@ -174,8 +281,8 @@ mod tests;
/// Use `use daemon_cli::prelude::*;` to import all commonly needed items.
pub mod prelude {
pub use crate::{
ColorSupport, CommandHandler, DaemonClient, DaemonHandle, DaemonServer, ErrorContextBuffer,
StartupReason, TerminalInfo,
ColorSupport, CommandContext, CommandHandler, DaemonClient, DaemonHandle, DaemonServer,
EnvVarFilter, ErrorContextBuffer, StartupReason, TerminalInfo, Theme,
};
pub use anyhow::Result;
pub use async_trait::async_trait;
Expand Down Expand Up @@ -271,7 +378,7 @@ fn auto_detect_daemon_name() -> String {
/// async fn handle(
/// &self,
/// command: &str,
/// terminal_info: TerminalInfo,
/// ctx: CommandContext,
/// mut output: impl AsyncWrite + Send + Unpin,
/// cancel_token: CancellationToken,
/// ) -> Result<i32> {
Expand Down Expand Up @@ -310,10 +417,10 @@ pub trait CommandHandler: Send + Sync {
/// This method may be called concurrently from multiple tasks. Ensure
/// your implementation is thread-safe if accessing shared state.
///
/// The `terminal_info` parameter contains information about the client's
/// terminal environment (width, height, color support, theme). Individual
/// fields may be `None` if detection failed. Use this to format output
/// appropriately for the client's terminal.
/// The `ctx` parameter contains information about the command execution
/// environment including terminal info (width, height, color support) and
/// any environment variables passed from the client. Use this to format
/// output appropriately and access client-side configuration.
///
/// Write output incrementally via `output`. Long-running operations should
/// check `cancel_token.is_cancelled()` to handle graceful cancellation.
Expand All @@ -323,7 +430,7 @@ pub trait CommandHandler: Send + Sync {
async fn handle(
&self,
command: &str,
terminal_info: TerminalInfo,
ctx: CommandContext,
output: impl AsyncWrite + Send + Unpin,
cancel_token: CancellationToken,
) -> Result<i32>;
Expand Down
Loading