From 52f4f5cec6b5a3847634521a8693c442fe61142b Mon Sep 17 00:00:00 2001 From: cpendery Date: Mon, 3 Aug 2026 13:56:17 -0700 Subject: [PATCH 1/2] build: split core shell-use & cli crates Signed-off-by: cpendery --- .github/workflows/ci.yml | 4 +- Cargo.lock | 19 +- Cargo.toml | 41 ++-- crates/shell-use-cli/Cargo.toml | 23 ++ .../shell-use-cli/src}/agent_context.rs | 0 {src => crates/shell-use-cli/src}/cli.rs | 6 +- crates/shell-use-cli/src/config.rs | 115 ++++++++++ crates/shell-use-cli/src/daemon.rs | 180 +++++++++++++++ .../mod.rs => crates/shell-use-cli/src/ipc.rs | 2 +- {src => crates/shell-use-cli/src}/main.rs | 12 +- {src => crates/shell-use-cli/src}/monitor.rs | 6 +- {src => crates/shell-use-cli/src}/skill.rs | 0 .../shell-use-cli/tests}/session_lifecycle.rs | 0 crates/shell-use/Cargo.toml | 30 +++ {src => crates/shell-use/src}/assert/color.rs | 0 {src => crates/shell-use/src}/assert/mod.rs | 0 .../shell-use/src}/assert/snapshot.rs | 0 {src => crates/shell-use/src}/config.rs | 132 +---------- .../mod.rs => crates/shell-use/src/engine.rs | 211 ++++-------------- {src => crates/shell-use/src}/input/keys.rs | 0 {src => crates/shell-use/src}/input/mod.rs | 0 {src => crates/shell-use/src}/input/mouse.rs | 0 crates/shell-use/src/lib.rs | 11 + .../daemon => crates/shell-use/src}/logger.rs | 2 +- {src => crates/shell-use/src}/protocol.rs | 0 {src => crates/shell-use/src}/render/mod.rs | 0 .../shell-use/src}/render/nerd_font.rs | 4 +- {src => crates/shell-use/src}/render/svg.rs | 0 .../shell-use/src}/session.rs | 2 +- {src => crates/shell-use/src}/shell/mod.rs | 25 ++- .../shell-use/src}/terminal/alacritty.rs | 0 .../shell-use/src}/terminal/cell.rs | 0 .../shell-use/src}/terminal/conformance.rs | 0 {src => crates/shell-use/src}/terminal/emu.rs | 0 .../shell-use/src}/terminal/integration.rs | 0 .../shell-use/src}/terminal/locator.rs | 0 {src => crates/shell-use/src}/terminal/mod.rs | 0 {src => crates/shell-use/src}/terminal/pty.rs | 0 {src => crates/shell-use/src}/trace/mod.rs | 0 .../shell-use/src}/trace/recorder.rs | 0 40 files changed, 468 insertions(+), 357 deletions(-) create mode 100644 crates/shell-use-cli/Cargo.toml rename {src => crates/shell-use-cli/src}/agent_context.rs (100%) rename {src => crates/shell-use-cli/src}/cli.rs (99%) create mode 100644 crates/shell-use-cli/src/config.rs create mode 100644 crates/shell-use-cli/src/daemon.rs rename src/ipc/mod.rs => crates/shell-use-cli/src/ipc.rs (98%) rename {src => crates/shell-use-cli/src}/main.rs (99%) rename {src => crates/shell-use-cli/src}/monitor.rs (98%) rename {src => crates/shell-use-cli/src}/skill.rs (100%) rename {tests => crates/shell-use-cli/tests}/session_lifecycle.rs (100%) create mode 100644 crates/shell-use/Cargo.toml rename {src => crates/shell-use/src}/assert/color.rs (100%) rename {src => crates/shell-use/src}/assert/mod.rs (100%) rename {src => crates/shell-use/src}/assert/snapshot.rs (100%) rename {src => crates/shell-use/src}/config.rs (50%) rename src/daemon/mod.rs => crates/shell-use/src/engine.rs (81%) rename {src => crates/shell-use/src}/input/keys.rs (100%) rename {src => crates/shell-use/src}/input/mod.rs (100%) rename {src => crates/shell-use/src}/input/mouse.rs (100%) create mode 100644 crates/shell-use/src/lib.rs rename {src/daemon => crates/shell-use/src}/logger.rs (97%) rename {src => crates/shell-use/src}/protocol.rs (100%) rename {src => crates/shell-use/src}/render/mod.rs (100%) rename {src => crates/shell-use/src}/render/nerd_font.rs (98%) rename {src => crates/shell-use/src}/render/svg.rs (100%) rename {src/daemon => crates/shell-use/src}/session.rs (99%) rename {src => crates/shell-use/src}/shell/mod.rs (90%) rename {src => crates/shell-use/src}/terminal/alacritty.rs (100%) rename {src => crates/shell-use/src}/terminal/cell.rs (100%) rename {src => crates/shell-use/src}/terminal/conformance.rs (100%) rename {src => crates/shell-use/src}/terminal/emu.rs (100%) rename {src => crates/shell-use/src}/terminal/integration.rs (100%) rename {src => crates/shell-use/src}/terminal/locator.rs (100%) rename {src => crates/shell-use/src}/terminal/mod.rs (100%) rename {src => crates/shell-use/src}/terminal/pty.rs (100%) rename {src => crates/shell-use/src}/trace/mod.rs (100%) rename {src => crates/shell-use/src}/trace/recorder.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9cad5f..b5e9bf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,9 @@ jobs: restore-keys: ${{ runner.os }}-cargo- - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo build - - run: cargo test + - run: cargo test --workspace - uses: actions/setup-python@v5 with: diff --git a/Cargo.lock b/Cargo.lock index 108563b..116c566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -873,19 +873,30 @@ dependencies = [ "bitflags 2.13.1", "clap", "compact_str", - "crossterm", - "dialoguer", "dirs", "flate2", - "interprocess", "portable-pty", "regex", "serde", "serde_json", - "sha2", "ttf-parser", ] +[[package]] +name = "shell-use-cli" +version = "0.0.1-beta.5" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "dialoguer", + "dirs", + "interprocess", + "serde_json", + "sha2", + "shell-use", +] + [[package]] name = "shell-words" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 0a956ac..7b23491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,28 @@ -[package] -name = "shell-use" +[workspace] +members = [ + "crates/shell-use", + "crates/shell-use-cli", +] +default-members = [ + "crates/shell-use", + "crates/shell-use-cli", +] +resolver = "2" + +[workspace.package] version = "0.0.1-beta.5" edition = "2021" -description = "A headless terminal CLI + daemon for driving, asserting on, and recording shells" license = "MIT" repository = "https://github.com/microsoft/shell-use" -readme = "README.md" - -[[bin]] -name = "shell-use" -path = "src/main.rs" -[profile.release] -strip = true -lto = true - -[dependencies] +[workspace.dependencies] alacritty_terminal = "0.26.0" anyhow = "1.0.102" -clap = { version = "4.6.1", features = ["derive", "env"] } +bitflags = "2.13.1" +clap = { version = "4.6.1", features = ["derive"] } +compact_str = "0.10.0" +crossterm = "0.28" +dialoguer = { version = "0.11", default-features = false } dirs = "6.0.0" flate2 = "1.1.9" interprocess = "2.4.2" @@ -27,8 +31,9 @@ regex = "1.12.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" -crossterm = "0.28" +shell-use = { path = "crates/shell-use" } ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } -dialoguer = { version = "0.11", default-features = false } -compact_str = "0.10.0" -bitflags = "2.13.1" + +[profile.release] +strip = true +lto = true diff --git a/crates/shell-use-cli/Cargo.toml b/crates/shell-use-cli/Cargo.toml new file mode 100644 index 0000000..73dfb7e --- /dev/null +++ b/crates/shell-use-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "shell-use-cli" +version.workspace = true +edition.workspace = true +description = "The shell-use command-line interface and per-session daemon" +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "shell-use" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, features = ["env"] } +crossterm.workspace = true +dialoguer.workspace = true +dirs.workspace = true +interprocess.workspace = true +serde_json.workspace = true +sha2.workspace = true +shell-use = { workspace = true, features = ["cli"] } diff --git a/src/agent_context.rs b/crates/shell-use-cli/src/agent_context.rs similarity index 100% rename from src/agent_context.rs rename to crates/shell-use-cli/src/agent_context.rs diff --git a/src/cli.rs b/crates/shell-use-cli/src/cli.rs similarity index 99% rename from src/cli.rs rename to crates/shell-use-cli/src/cli.rs index 4b05288..013673f 100644 --- a/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -1,8 +1,8 @@ use clap::{Args, Parser, Subcommand}; -use crate::config::{DEFAULT_COLS, DEFAULT_ROWS}; -use crate::protocol::TimeoutDefaults; -use crate::shell::Shell; +use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; +use shell_use::protocol::TimeoutDefaults; +use shell_use::shell::Shell; /// Per-class default timeouts for a session, in milliseconds. #[derive(Args, Clone, Copy, Default)] diff --git a/crates/shell-use-cli/src/config.rs b/crates/shell-use-cli/src/config.rs new file mode 100644 index 0000000..9d5d623 --- /dev/null +++ b/crates/shell-use-cli/src/config.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +pub const SHUTDOWN_DRAIN_MS: u64 = 2_000; +pub const MONITOR_FRAME_MS: u64 = 50; +pub const IDLE_TIMEOUT_MS: u64 = 4 * 60 * 60 * 1_000; +pub const IDLE_CHECK_INTERVAL_MS: u64 = 5 * 60 * 1_000; + +pub fn home_dir() -> PathBuf { + shell_use::config::home_dir() +} + +pub fn ensure_home() -> std::io::Result { + let dir = home_dir(); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +pub fn pid_file(session: &str) -> PathBuf { + home_dir().join(format!("{session}.pid")) +} + +pub fn log_file(session: &str) -> PathBuf { + home_dir().join(format!("{session}.log")) +} + +pub fn recording_dir() -> PathBuf { + if let Ok(dir) = std::env::var("SHELL_USE_HOME") { + return PathBuf::from(dir).join("recordings"); + } + dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("shell-use") +} + +pub fn recording_file(session: &str) -> PathBuf { + recording_dir().join(format!("{session}.cast")) +} + +const SOCKET_PATH_MAX: usize = 100; +const SOCKET_DIGEST_HEX_LEN: usize = 16; + +pub fn socket_name(session: &str) -> String { + if cfg!(windows) { + return format!("shell-use-{session}.sock"); + } + socket_path_in(&home_dir(), session) + .to_string_lossy() + .into_owned() +} + +fn socket_path_in(dir: &std::path::Path, session: &str) -> PathBuf { + let path = dir.join(format!("{session}.sock")); + if path.as_os_str().len() <= SOCKET_PATH_MAX { + return path; + } + let digest = format!("{:x}", Sha256::digest(session.as_bytes())); + dir.join(format!("{}.sock", &digest[..SOCKET_DIGEST_HEX_LEN])) +} + +pub fn session_name_from_env(explicit: Option) -> String { + explicit + .or_else(|| std::env::var("SHELL_USE_SESSION").ok()) + .unwrap_or_else(|| "default".to_string()) +} + +pub fn session_was_specified(explicit: &Option) -> bool { + explicit.is_some() || std::env::var("SHELL_USE_SESSION").is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_short_socket_path_keeps_the_session_name() { + let dir = PathBuf::from("/tmp/shell-use"); + assert_eq!( + socket_path_in(&dir, "work"), + PathBuf::from("/tmp/shell-use/work.sock") + ); + } + + #[test] + fn a_long_socket_path_stays_within_sun_path() { + let dir = + PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd"); + let session = format!("shell-use-{}", "x".repeat(50)); + let path = socket_path_in(&dir, &session); + assert!(path.as_os_str().len() <= SOCKET_PATH_MAX); + assert_eq!(path, socket_path_in(&dir, &session)); + } + + #[test] + fn long_socket_path_matches_the_binding_digest() { + let dir = + PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd34"); + assert_eq!( + socket_path_in(&dir, "helpers-track-54321-9f8e7d6c-1"), + dir.join("9ba800cbf25eaece.sock") + ); + } + + #[test] + fn shortened_socket_names_stay_distinct_per_session() { + let dir = + PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd"); + let long = "y".repeat(60); + assert_ne!( + socket_path_in(&dir, &format!("a{long}")), + socket_path_in(&dir, &format!("b{long}")), + ); + } +} diff --git a/crates/shell-use-cli/src/daemon.rs b/crates/shell-use-cli/src/daemon.rs new file mode 100644 index 0000000..83a472d --- /dev/null +++ b/crates/shell-use-cli/src/daemon.rs @@ -0,0 +1,180 @@ +//! CLI daemon host: local socket listener, idle watchdog, monitor streaming, +//! and process state files around the reusable in-process engine. + +use std::io::Write; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::traits::ListenerExt; +use interprocess::local_socket::Stream; + +use shell_use::engine::Engine; +use shell_use::logger::Logger; +use shell_use::protocol::Request; + +use crate::{config, ipc, monitor}; + +pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { + config::ensure_home()?; + sweep_recordings(&session_name); + let socket = config::socket_name(&session_name); + let listener = ipc::listen(&socket)?; + std::fs::write( + config::pid_file(&session_name), + std::process::id().to_string(), + ) + .ok(); + + let logger = if verbose { + match Logger::to_file(&config::log_file(&session_name)) { + Ok(logger) => Arc::new(logger), + Err(_) => Arc::new(Logger::disabled()), + } + } else { + Arc::new(Logger::disabled()) + }; + let logging = logger.enabled(); + let engine = Arc::new(Engine::new( + session_name.clone(), + logger, + config::recording_file(&session_name), + )); + engine.log_event(&format!( + "daemon start session={session_name} pid={}", + std::process::id() + )); + let last_activity = Arc::new(Mutex::new(Instant::now())); + spawn_idle_watchdog( + Arc::clone(&engine), + Arc::clone(&last_activity), + session_name.clone(), + ); + + for conn in listener.incoming() { + let Ok(mut conn) = conn else { continue }; + let req = match ipc::read_request(&conn) { + Ok(request) => request, + Err(_) => continue, + }; + *last_activity.lock().unwrap() = Instant::now(); + if let Request::Monitor { cols, rows } = req { + spawn_monitor( + Arc::clone(&engine), + conn, + (cols, rows), + session_name.clone(), + ); + continue; + } + let enrich = match &req { + Request::Open { .. } => Some(false), + Request::Status => Some(true), + _ => None, + }; + let (mut response, shutdown) = engine.handle(req); + if let Some(status) = enrich { + enrich_cli_response(&mut response, &session_name, logging, status); + } + let _ = ipc::write_response(&mut conn, &response); + if shutdown { + ipc::drain_peer(conn, Duration::from_millis(config::SHUTDOWN_DRAIN_MS)); + break; + } + } + + cleanup(&session_name); + Ok(()) +} + +fn enrich_cli_response( + response: &mut shell_use::protocol::Response, + session: &str, + logging: bool, + status: bool, +) { + let Some(data) = response + .data + .as_mut() + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + data.insert("pid".to_string(), serde_json::json!(std::process::id())); + if status { + data.insert( + "log".to_string(), + if logging { + serde_json::json!(config::log_file(session).to_string_lossy()) + } else { + serde_json::Value::Null + }, + ); + data.insert( + "version".to_string(), + serde_json::json!(env!("CARGO_PKG_VERSION")), + ); + } +} + +fn cleanup(session: &str) { + let _ = std::fs::remove_file(config::pid_file(session)); + if !cfg!(windows) { + let _ = std::fs::remove_file(config::socket_name(session)); + } +} + +fn spawn_idle_watchdog(engine: Arc, last_activity: Arc>, session: String) { + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_millis(config::IDLE_CHECK_INTERVAL_MS)); + let idle = last_activity.lock().unwrap().elapsed(); + if idle >= Duration::from_millis(config::IDLE_TIMEOUT_MS) { + engine.log_event(&format!( + "idle timeout: no activity for {}s, shutting down", + idle.as_secs() + )); + let _ = engine.handle(Request::Close); + cleanup(&session); + std::process::exit(0); + } + }); +} + +fn spawn_monitor(engine: Arc, mut conn: Stream, viewer: (u16, u16), session: String) { + std::thread::spawn(move || { + engine.log_event("monitor attached"); + loop { + let frame = engine.frame().map(|frame| monitor::Frame { + grid: frame.grid, + cursor: frame.cursor, + size: frame.size, + exited: frame.exited, + shell: frame.shell, + }); + let bytes = monitor::render_frame(frame.as_ref(), viewer, &session); + if conn.write_all(&bytes).is_err() || conn.flush().is_err() { + break; + } + std::thread::sleep(Duration::from_millis(config::MONITOR_FRAME_MS)); + } + engine.log_event("monitor detached"); + }); +} + +fn sweep_recordings(current: &str) { + let Ok(entries) = std::fs::read_dir(config::recording_dir()) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("cast") { + continue; + } + let Some(session) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if session != current && ipc::is_running(&config::socket_name(session)) { + continue; + } + let _ = std::fs::remove_file(path); + } +} diff --git a/src/ipc/mod.rs b/crates/shell-use-cli/src/ipc.rs similarity index 98% rename from src/ipc/mod.rs rename to crates/shell-use-cli/src/ipc.rs index 00e4fdc..4183ec3 100644 --- a/src/ipc/mod.rs +++ b/crates/shell-use-cli/src/ipc.rs @@ -9,7 +9,7 @@ use interprocess::local_socket::{GenericFilePath, GenericNamespaced, ListenerOpt pub use interprocess::local_socket::Stream; -use crate::protocol::{Request, Response}; +use shell_use::protocol::{Request, Response}; fn to_name(raw: &str) -> std::io::Result> { if cfg!(windows) { diff --git a/src/main.rs b/crates/shell-use-cli/src/main.rs similarity index 99% rename from src/main.rs rename to crates/shell-use-cli/src/main.rs index 94962ba..bbb60cd 100644 --- a/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -1,17 +1,10 @@ mod agent_context; -mod assert; mod cli; mod config; mod daemon; -mod input; mod ipc; mod monitor; -mod protocol; -mod render; -mod shell; mod skill; -mod terminal; -mod trace; use std::path::Path; use std::time::{Duration, Instant}; @@ -19,10 +12,9 @@ use std::time::{Duration, Instant}; use clap::{CommandFactory, Parser}; use cli::{Cli, Command, DaemonCmd, ExpectCmd, GetArg, MouseCmd, WaitCmd}; -use protocol::{GetField, MouseAction, Request, Response}; - +use shell_use::protocol::{GetField, MouseAction, Request, Response}; /// Long-form agent skill manifest, printed by `shell-use skill`. -const SKILL_MD: &str = include_str!("../SKILL.md"); +const SKILL_MD: &str = include_str!("../../../SKILL.md"); fn main() { let cli = Cli::parse(); diff --git a/src/monitor.rs b/crates/shell-use-cli/src/monitor.rs similarity index 98% rename from src/monitor.rs rename to crates/shell-use-cli/src/monitor.rs index a110bf6..4fb14c8 100644 --- a/src/monitor.rs +++ b/crates/shell-use-cli/src/monitor.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle}; +use shell_use::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle}; const BORDER: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m"; @@ -259,7 +259,7 @@ enum Action { fn stream_loop(socket: &str) -> i32 { use crate::ipc; - use crate::protocol::Request; + use shell_use::protocol::Request; loop { let (vcols, vrows) = crossterm::terminal::size().unwrap_or((80, 24)); @@ -368,7 +368,7 @@ mod tests { /// foreground color. #[test] fn sgr_survives_a_round_trip_through_the_emulator() { - use crate::terminal::{alacritty::AlacrittyEmu, cell::NamedColor, emu::Emulator}; + use shell_use::terminal::{alacritty::AlacrittyEmu, cell::NamedColor, emu::Emulator}; let styles = [ Style { diff --git a/src/skill.rs b/crates/shell-use-cli/src/skill.rs similarity index 100% rename from src/skill.rs rename to crates/shell-use-cli/src/skill.rs diff --git a/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs similarity index 100% rename from tests/session_lifecycle.rs rename to crates/shell-use-cli/tests/session_lifecycle.rs diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml new file mode 100644 index 0000000..ba134d0 --- /dev/null +++ b/crates/shell-use/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "shell-use" +version.workspace = true +edition.workspace = true +description = "In-process terminal automation, inspection, assertions, and recording" +license.workspace = true +repository.workspace = true +readme = "../../README.md" + +[lib] +name = "shell_use" +path = "src/lib.rs" + +[features] +default = [] +cli = ["dep:clap"] + +[dependencies] +alacritty_terminal.workspace = true +anyhow.workspace = true +bitflags.workspace = true +clap = { workspace = true, optional = true } +compact_str.workspace = true +dirs.workspace = true +flate2.workspace = true +portable-pty.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +ttf-parser.workspace = true diff --git a/src/assert/color.rs b/crates/shell-use/src/assert/color.rs similarity index 100% rename from src/assert/color.rs rename to crates/shell-use/src/assert/color.rs diff --git a/src/assert/mod.rs b/crates/shell-use/src/assert/mod.rs similarity index 100% rename from src/assert/mod.rs rename to crates/shell-use/src/assert/mod.rs diff --git a/src/assert/snapshot.rs b/crates/shell-use/src/assert/snapshot.rs similarity index 100% rename from src/assert/snapshot.rs rename to crates/shell-use/src/assert/snapshot.rs diff --git a/src/config.rs b/crates/shell-use/src/config.rs similarity index 50% rename from src/config.rs rename to crates/shell-use/src/config.rs index 7384d67..fed3469 100644 --- a/src/config.rs +++ b/crates/shell-use/src/config.rs @@ -1,21 +1,10 @@ use std::path::PathBuf; -use sha2::{Digest, Sha256}; - pub const DEFAULT_COLS: u16 = 80; pub const DEFAULT_ROWS: u16 = 30; pub const POLL_DELAY_MS: u64 = 50; /// Cap for `open`'s implicit prompt wait when no `ready` budget is configured. pub const OPEN_READY_CAP_MS: u64 = 8_000; -/// How long shutdown waits for the client to read its final response. -pub const SHUTDOWN_DRAIN_MS: u64 = 2_000; -/// Monitor refresh interval (~20fps). -pub const MONITOR_FRAME_MS: u64 = 50; -/// Idle timeout: the daemon shuts itself down after this long without -/// servicing a request. -pub const IDLE_TIMEOUT_MS: u64 = 4 * 60 * 60 * 1_000; -/// How often the idle watchdog checks for inactivity. -pub const IDLE_CHECK_INTERVAL_MS: u64 = 5 * 60 * 1_000; /// The kind of thing a wait or assertion is blocking on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,7 +61,7 @@ fn parse_timeout_ms(raw: &str) -> Option { raw.trim().parse::().ok().filter(|ms| *ms > 0) } -/// Root directory for all daemon state (sockets, pids, logs). +/// Root directory for shell-use runtime data. /// Override with `SHELL_USE_HOME`. pub fn home_dir() -> PathBuf { if let Ok(dir) = std::env::var("SHELL_USE_HOME") { @@ -82,76 +71,6 @@ pub fn home_dir() -> PathBuf { base.join(".shell-use") } -pub fn ensure_home() -> std::io::Result { - let dir = home_dir(); - std::fs::create_dir_all(&dir)?; - Ok(dir) -} - -pub fn pid_file(session: &str) -> PathBuf { - home_dir().join(format!("{session}.pid")) -} - -pub fn log_file(session: &str) -> PathBuf { - home_dir().join(format!("{session}.log")) -} - -/// Directory for session recordings, in the user's XDG cache. Recordings -/// persist after a session ends (so they remain retrievable) and are cleared -/// per-session when that session's daemon next starts. -/// -/// Honors `SHELL_USE_HOME` for test isolation. -pub fn recording_dir() -> PathBuf { - if let Ok(dir) = std::env::var("SHELL_USE_HOME") { - return PathBuf::from(dir).join("recordings"); - } - dirs::cache_dir() - .unwrap_or_else(std::env::temp_dir) - .join("shell-use") -} - -/// Always-on session recording in asciinema v2 cast format, stored in the XDG -/// cache by session name (e.g. `/shell-use/.cast`). -pub fn recording_file(session: &str) -> PathBuf { - recording_dir().join(format!("{session}.cast")) -} - -const SOCKET_PATH_MAX: usize = 100; -const SOCKET_DIGEST_HEX_LEN: usize = 16; - -/// Platform-appropriate socket name for a session. -/// -/// On Windows this is a namespaced pipe name; on Unix it is a filesystem path -/// inside the home directory. -pub fn socket_name(session: &str) -> String { - if cfg!(windows) { - return format!("shell-use-{session}.sock"); - } - socket_path_in(&home_dir(), session) - .to_string_lossy() - .into_owned() -} - -fn socket_path_in(dir: &std::path::Path, session: &str) -> PathBuf { - let path = dir.join(format!("{session}.sock")); - if path.as_os_str().len() <= SOCKET_PATH_MAX { - return path; - } - let digest = format!("{:x}", Sha256::digest(session.as_bytes())); - dir.join(format!("{}.sock", &digest[..SOCKET_DIGEST_HEX_LEN])) -} - -pub fn session_name_from_env(explicit: Option) -> String { - explicit - .or_else(|| std::env::var("SHELL_USE_SESSION").ok()) - .unwrap_or_else(|| "default".to_string()) -} - -/// Whether the user actually named a session. -pub fn session_was_specified(explicit: &Option) -> bool { - explicit.is_some() || std::env::var("SHELL_USE_SESSION").is_ok() -} - #[cfg(test)] mod tests { use super::*; @@ -183,55 +102,6 @@ mod tests { assert_eq!(TimeoutClass::Ready.built_in_ms(), 30_000); } - #[test] - fn a_short_socket_path_keeps_the_session_name() { - let dir = PathBuf::from("/tmp/shell-use"); - assert_eq!( - socket_path_in(&dir, "work"), - PathBuf::from("/tmp/shell-use/work.sock") - ); - } - - #[test] - fn a_long_socket_path_stays_within_sun_path() { - let dir = - PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd"); - let session = format!("shell-use-{}", "x".repeat(50)); - let path = socket_path_in(&dir, &session); - assert!( - path.as_os_str().len() <= SOCKET_PATH_MAX, - "{} is {} bytes, too long for sun_path", - path.display(), - path.as_os_str().len() - ); - assert_eq!( - path, - socket_path_in(&dir, &session), - "the shortened name must be stable so the CLI and daemon agree" - ); - } - - #[test] - fn long_socket_path_matches_the_binding_digest() { - let dir = - PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd34"); - assert_eq!( - socket_path_in(&dir, "helpers-track-54321-9f8e7d6c-1"), - dir.join("9ba800cbf25eaece.sock") - ); - } - - #[test] - fn shortened_socket_names_stay_distinct_per_session() { - let dir = - PathBuf::from("/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd"); - let long = "y".repeat(60); - assert_ne!( - socket_path_in(&dir, &format!("a{long}")), - socket_path_in(&dir, &format!("b{long}")), - ); - } - #[test] fn each_class_has_a_distinct_env_var() { let classes = [ diff --git a/src/daemon/mod.rs b/crates/shell-use/src/engine.rs similarity index 81% rename from src/daemon/mod.rs rename to crates/shell-use/src/engine.rs index ea82917..be5175b 100644 --- a/src/daemon/mod.rs +++ b/crates/shell-use/src/engine.rs @@ -1,36 +1,27 @@ -//! Per-session daemon: owns one [`Session`], listens on the session socket, and -//! services CLI requests (input, inspection, waits, assertions, recording). +//! Reusable in-process terminal engine. -pub mod logger; -pub mod session; - -use std::io::Write; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde_json::json; -use interprocess::local_socket::traits::ListenerExt; -use interprocess::local_socket::Stream; - use crate::assert::color::{self, Expected}; use crate::assert::snapshot::{self, SnapshotStatus}; use crate::config::{self, POLL_DELAY_MS}; use crate::input::{keys, mouse}; -use crate::ipc; -use crate::monitor; +use crate::logger::Logger; use crate::protocol::{ErrorKind, GetField, MouseAction, Request, Response, TimeoutDefaults}; +use crate::session::{Session, TermState}; use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::locator::{self, Pattern}; -use logger::Logger; -use session::{Session, TermState}; -pub struct Daemon { +pub struct Engine { name: String, session: Mutex>, live: Arc>>, logger: Arc, - last_activity: Mutex, + recording_path: PathBuf, } /// The current session's renderable state, shared with monitor threads so they @@ -41,113 +32,12 @@ struct LiveTarget { shell: Option<&'static str>, } -/// Run the daemon for `session_name` until it is closed. -pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { - config::ensure_home()?; - sweep_recordings(&session_name); - let socket = config::socket_name(&session_name); - let listener = ipc::listen(&socket)?; - std::fs::write( - config::pid_file(&session_name), - std::process::id().to_string(), - ) - .ok(); - - let logger = if verbose { - match Logger::to_file(&config::log_file(&session_name)) { - Ok(l) => Arc::new(l), - Err(_) => Arc::new(Logger::disabled()), - } - } else { - Arc::new(Logger::disabled()) - }; - logger.event(&format!( - "daemon start session={session_name} pid={}", - std::process::id() - )); - - let daemon = Arc::new(Daemon { - name: session_name.clone(), - session: Mutex::new(None), - live: Arc::new(Mutex::new(None)), - logger, - last_activity: Mutex::new(Instant::now()), - }); - - spawn_idle_watchdog(Arc::clone(&daemon)); - - for conn in listener.incoming() { - let Ok(mut conn) = conn else { continue }; - let req = match ipc::read_request(&conn) { - Ok(r) => r, - Err(_) => continue, - }; - *daemon.last_activity.lock().unwrap() = Instant::now(); - if let Request::Monitor { cols, rows } = req { - daemon.spawn_monitor(conn, (cols, rows)); - continue; - } - let (resp, shutdown) = daemon.handle(req); - let _ = ipc::write_response(&mut conn, &resp); - if shutdown { - ipc::drain_peer(conn, Duration::from_millis(config::SHUTDOWN_DRAIN_MS)); - break; - } - } - - cleanup(&session_name); - Ok(()) -} - -fn cleanup(session: &str) { - let _ = std::fs::remove_file(config::pid_file(session)); - if !cfg!(windows) { - let _ = std::fs::remove_file(config::socket_name(session)); - } -} - -/// Shut the daemon down once it has gone `IDLE_TIMEOUT_MS` without servicing a -/// request, checked every `IDLE_CHECK_INTERVAL_MS`. Kills the session's pty, -/// removes the daemon's state files, and exits the process. -fn spawn_idle_watchdog(daemon: Arc) { - std::thread::spawn(move || loop { - std::thread::sleep(Duration::from_millis(config::IDLE_CHECK_INTERVAL_MS)); - let idle = daemon.last_activity.lock().unwrap().elapsed(); - if idle >= Duration::from_millis(config::IDLE_TIMEOUT_MS) { - daemon.logger.event(&format!( - "idle timeout: no activity for {}s, shutting down", - idle.as_secs() - )); - if let Some(s) = daemon.session.lock().unwrap().as_ref() { - s.kill(); - } - cleanup(&daemon.name); - std::process::exit(0); - } - }); -} - -/// Remove leftover `.cast` recordings at daemon start. Recordings of sessions -/// that are still running (their socket is connectable) are kept; the rest are -/// deleted. Called before this daemon's own socket is listening, so the current -/// session's stale cast is included. -fn sweep_recordings(current: &str) { - let Ok(entries) = std::fs::read_dir(config::recording_dir()) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("cast") { - continue; - } - let Some(session) = path.file_stem().and_then(|s| s.to_str()) else { - continue; - }; - if session != current && ipc::is_running(&config::socket_name(session)) { - continue; - } - let _ = std::fs::remove_file(&path); - } +pub struct LiveFrame { + pub grid: Vec>, + pub cursor: (u16, u16), + pub size: (u16, u16), + pub exited: Option, + pub shell: Option<&'static str>, } /// One-line request description for the verbose log. `Open` redacts env values @@ -171,8 +61,18 @@ fn req_summary(req: &Request) -> String { } } -impl Daemon { - fn handle(&self, req: Request) -> (Response, bool) { +impl Engine { + pub fn new(name: String, logger: Arc, recording_path: PathBuf) -> Self { + Engine { + name, + session: Mutex::new(None), + live: Arc::new(Mutex::new(None)), + logger, + recording_path, + } + } + + pub fn handle(&self, req: Request) -> (Response, bool) { if self.logger.enabled() { self.logger.event(&format!("req {}", req_summary(&req))); } @@ -225,7 +125,7 @@ impl Daemon { env, timeouts, self.logger.clone(), - config::recording_file(&self.name), + self.recording_path.clone(), ) { Ok(s) => { let shell_pid = s.pid(); @@ -254,45 +154,35 @@ impl Daemon { *self.session.lock().unwrap() = Some(s); *self.live.lock().unwrap() = Some(live); Response::with(json!({ - "pid": std::process::id(), "shell_pid": shell_pid, "session": self.name, "ready": ready, - "recording": config::recording_file(&self.name).to_string_lossy(), + "recording": self.recording_path.to_string_lossy(), })) } + Err(e) => Response::internal(format!("failed to open session: {e}")), } } fn status(&self) -> Response { - let log = self - .logger - .enabled() - .then(|| config::log_file(&self.name).to_string_lossy().into_owned()); let guard = self.session.lock().unwrap(); match guard.as_ref() { Some(s) => { let st = s.state.lock().unwrap(); Response::with(json!({ "session": self.name, - "pid": std::process::id(), "shell_pid": s.pid(), "cols": s.cols, "rows": s.rows, "shell": s.shell.map(|sh| sh.as_str()), "exited": st.exited, "timeouts": effective_timeouts(s), - "log": log, - "version": env!("CARGO_PKG_VERSION"), })) } None => Response::with(json!({ "session": self.name, - "pid": std::process::id(), "shell_pid": null, - "log": log, - "version": env!("CARGO_PKG_VERSION"), })), } } @@ -305,39 +195,22 @@ impl Daemon { } } - /// Stream framed, full-color frames of the live session to a monitor client - /// until it detaches. Runs on its own thread so the accept loop keeps - /// serving the agent, and reads only the shared emulator state (never the - /// session lock) so long `wait`s don't stall the view. - fn spawn_monitor(&self, conn: Stream, viewer: (u16, u16)) { - let live = self.live.clone(); - let name = self.name.clone(); - let logger = self.logger.clone(); - std::thread::spawn(move || { - logger.event("monitor attached"); - let mut conn = conn; - loop { - let frame = { - let guard = live.lock().unwrap(); - guard.as_ref().map(|t| { - let st = t.state.lock().unwrap(); - monitor::Frame { - grid: st.emu.viewable_rows(), - cursor: st.emu.cursor(), - size: st.emu.size(), - exited: st.exited, - shell: t.shell, - } - }) - }; - let bytes = monitor::render_frame(frame.as_ref(), viewer, &name); - if conn.write_all(&bytes).is_err() || conn.flush().is_err() { - break; - } - std::thread::sleep(Duration::from_millis(config::MONITOR_FRAME_MS)); + pub fn frame(&self) -> Option { + let live = self.live.lock().unwrap(); + live.as_ref().map(|target| { + let state = target.state.lock().unwrap(); + LiveFrame { + grid: state.emu.viewable_rows(), + cursor: state.emu.cursor(), + size: state.emu.size(), + exited: state.exited, + shell: target.shell, } - logger.event("monitor detached"); - }); + }) + } + + pub fn log_event(&self, message: &str) { + self.logger.event(message); } } diff --git a/src/input/keys.rs b/crates/shell-use/src/input/keys.rs similarity index 100% rename from src/input/keys.rs rename to crates/shell-use/src/input/keys.rs diff --git a/src/input/mod.rs b/crates/shell-use/src/input/mod.rs similarity index 100% rename from src/input/mod.rs rename to crates/shell-use/src/input/mod.rs diff --git a/src/input/mouse.rs b/crates/shell-use/src/input/mouse.rs similarity index 100% rename from src/input/mouse.rs rename to crates/shell-use/src/input/mouse.rs diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs new file mode 100644 index 0000000..4656175 --- /dev/null +++ b/crates/shell-use/src/lib.rs @@ -0,0 +1,11 @@ +pub mod assert; +pub mod config; +pub mod engine; +pub mod input; +pub mod logger; +pub mod protocol; +pub mod render; +pub mod session; +pub mod shell; +pub mod terminal; +pub mod trace; diff --git a/src/daemon/logger.rs b/crates/shell-use/src/logger.rs similarity index 97% rename from src/daemon/logger.rs rename to crates/shell-use/src/logger.rs index e469131..06719e4 100644 --- a/src/daemon/logger.rs +++ b/crates/shell-use/src/logger.rs @@ -1,4 +1,4 @@ -//! Optional verbose logging of everything the daemon reads from and writes to +//! Optional verbose logging of everything a terminal session reads and writes //! the PTY, plus lifecycle events. Modeled on inshellisense's data log: every //! record is timestamped and byte-escaped so control sequences are visible. //! diff --git a/src/protocol.rs b/crates/shell-use/src/protocol.rs similarity index 100% rename from src/protocol.rs rename to crates/shell-use/src/protocol.rs diff --git a/src/render/mod.rs b/crates/shell-use/src/render/mod.rs similarity index 100% rename from src/render/mod.rs rename to crates/shell-use/src/render/mod.rs diff --git a/src/render/nerd_font.rs b/crates/shell-use/src/render/nerd_font.rs similarity index 98% rename from src/render/nerd_font.rs rename to crates/shell-use/src/render/nerd_font.rs index 2ce5032..14bf6b5 100644 --- a/src/render/nerd_font.rs +++ b/crates/shell-use/src/render/nerd_font.rs @@ -8,7 +8,7 @@ use crate::terminal::cell::EmuCell; // Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE. const FONT_DATA: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" + "/../../assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" )); struct Glyph { @@ -260,7 +260,7 @@ mod tests { fn bundled_font_includes_its_mit_license() { let license = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/assets/nerd-fonts/LICENSE" + "/../../assets/nerd-fonts/LICENSE" )); assert!(license.starts_with("The MIT License (MIT)")); assert!(license.contains("Copyright (c) 2014 Ryan L McIntyre")); diff --git a/src/render/svg.rs b/crates/shell-use/src/render/svg.rs similarity index 100% rename from src/render/svg.rs rename to crates/shell-use/src/render/svg.rs diff --git a/src/daemon/session.rs b/crates/shell-use/src/session.rs similarity index 99% rename from src/daemon/session.rs rename to crates/shell-use/src/session.rs index 28a0bf0..41cbd35 100644 --- a/src/daemon/session.rs +++ b/crates/shell-use/src/session.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Instant; -use crate::daemon::logger::Logger; +use crate::logger::Logger; use crate::shell::{self, Shell}; use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::emu::Emulator; diff --git a/src/shell/mod.rs b/crates/shell-use/src/shell/mod.rs similarity index 90% rename from src/shell/mod.rs rename to crates/shell-use/src/shell/mod.rs index fc90fa7..a9147cf 100644 --- a/src/shell/mod.rs +++ b/crates/shell-use/src/shell/mod.rs @@ -4,9 +4,10 @@ use serde::{Deserialize, Serialize}; use crate::config::home_dir; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] #[serde(rename_all = "lowercase")] -#[clap(rename_all = "lowercase")] +#[cfg_attr(feature = "cli", clap(rename_all = "lowercase"))] pub enum Shell { Bash, Powershell, @@ -68,43 +69,43 @@ pub fn write_integration_scripts() -> std::io::Result<()> { let files: &[(&str, &str)] = &[ ( "shellIntegration.bash", - include_str!("../../shell/shellIntegration.bash"), + include_str!("../../../../shell/shellIntegration.bash"), ), ( "shellIntegration.fish", - include_str!("../../shell/shellIntegration.fish"), + include_str!("../../../../shell/shellIntegration.fish"), ), ( "shellIntegration.ps1", - include_str!("../../shell/shellIntegration.ps1"), + include_str!("../../../../shell/shellIntegration.ps1"), ), ( "shellIntegration.xsh", - include_str!("../../shell/shellIntegration.xsh"), + include_str!("../../../../shell/shellIntegration.xsh"), ), ( "shellIntegration.elv", - include_str!("../../shell/shellIntegration.elv"), + include_str!("../../../../shell/shellIntegration.elv"), ), ( "shellIntegration.nu", - include_str!("../../shell/shellIntegration.nu"), + include_str!("../../../../shell/shellIntegration.nu"), ), ( "shellIntegration-rc.zsh", - include_str!("../../shell/shellIntegration-rc.zsh"), + include_str!("../../../../shell/shellIntegration-rc.zsh"), ), ( "shellIntegration-profile.zsh", - include_str!("../../shell/shellIntegration-profile.zsh"), + include_str!("../../../../shell/shellIntegration-profile.zsh"), ), ( "shellIntegration-env.zsh", - include_str!("../../shell/shellIntegration-env.zsh"), + include_str!("../../../../shell/shellIntegration-env.zsh"), ), ( "shellIntegration-login.zsh", - include_str!("../../shell/shellIntegration-login.zsh"), + include_str!("../../../../shell/shellIntegration-login.zsh"), ), ]; for (name, body) in files { diff --git a/src/terminal/alacritty.rs b/crates/shell-use/src/terminal/alacritty.rs similarity index 100% rename from src/terminal/alacritty.rs rename to crates/shell-use/src/terminal/alacritty.rs diff --git a/src/terminal/cell.rs b/crates/shell-use/src/terminal/cell.rs similarity index 100% rename from src/terminal/cell.rs rename to crates/shell-use/src/terminal/cell.rs diff --git a/src/terminal/conformance.rs b/crates/shell-use/src/terminal/conformance.rs similarity index 100% rename from src/terminal/conformance.rs rename to crates/shell-use/src/terminal/conformance.rs diff --git a/src/terminal/emu.rs b/crates/shell-use/src/terminal/emu.rs similarity index 100% rename from src/terminal/emu.rs rename to crates/shell-use/src/terminal/emu.rs diff --git a/src/terminal/integration.rs b/crates/shell-use/src/terminal/integration.rs similarity index 100% rename from src/terminal/integration.rs rename to crates/shell-use/src/terminal/integration.rs diff --git a/src/terminal/locator.rs b/crates/shell-use/src/terminal/locator.rs similarity index 100% rename from src/terminal/locator.rs rename to crates/shell-use/src/terminal/locator.rs diff --git a/src/terminal/mod.rs b/crates/shell-use/src/terminal/mod.rs similarity index 100% rename from src/terminal/mod.rs rename to crates/shell-use/src/terminal/mod.rs diff --git a/src/terminal/pty.rs b/crates/shell-use/src/terminal/pty.rs similarity index 100% rename from src/terminal/pty.rs rename to crates/shell-use/src/terminal/pty.rs diff --git a/src/trace/mod.rs b/crates/shell-use/src/trace/mod.rs similarity index 100% rename from src/trace/mod.rs rename to crates/shell-use/src/trace/mod.rs diff --git a/src/trace/recorder.rs b/crates/shell-use/src/trace/recorder.rs similarity index 100% rename from src/trace/recorder.rs rename to crates/shell-use/src/trace/recorder.rs From b079a0307bcb4193f8fa7dee9c8a70327693110f Mon Sep 17 00:00:00 2001 From: cpendery Date: Mon, 3 Aug 2026 14:08:43 -0700 Subject: [PATCH 2/2] fix: asset locations Signed-off-by: cpendery --- Cargo.lock | 1 - crates/shell-use-cli/Cargo.toml | 2 +- crates/shell-use-cli/src/cli.rs | 58 +++++++++++++++++- crates/shell-use-cli/src/main.rs | 2 +- crates/shell-use/Cargo.toml | 5 -- .../shell-use/assets}/nerd-fonts/LICENSE | 0 .../SymbolsNerdFontMono-Regular.ttf | Bin .../shell-use/shell}/shellIntegration-env.zsh | 0 .../shell}/shellIntegration-login.zsh | 0 .../shell}/shellIntegration-profile.zsh | 0 .../shell-use/shell}/shellIntegration-rc.zsh | 0 .../shell-use/shell}/shellIntegration.bash | 0 .../shell-use/shell}/shellIntegration.elv | 0 .../shell-use/shell}/shellIntegration.fish | 0 .../shell-use/shell}/shellIntegration.nu | 0 .../shell-use/shell}/shellIntegration.ps1 | 0 .../shell-use/shell}/shellIntegration.xsh | 0 crates/shell-use/src/render/nerd_font.rs | 4 +- crates/shell-use/src/shell/mod.rs | 22 +++---- 19 files changed, 71 insertions(+), 23 deletions(-) rename {assets => crates/shell-use/assets}/nerd-fonts/LICENSE (100%) rename {assets => crates/shell-use/assets}/nerd-fonts/SymbolsNerdFontMono-Regular.ttf (100%) rename {shell => crates/shell-use/shell}/shellIntegration-env.zsh (100%) rename {shell => crates/shell-use/shell}/shellIntegration-login.zsh (100%) rename {shell => crates/shell-use/shell}/shellIntegration-profile.zsh (100%) rename {shell => crates/shell-use/shell}/shellIntegration-rc.zsh (100%) rename {shell => crates/shell-use/shell}/shellIntegration.bash (100%) rename {shell => crates/shell-use/shell}/shellIntegration.elv (100%) rename {shell => crates/shell-use/shell}/shellIntegration.fish (100%) rename {shell => crates/shell-use/shell}/shellIntegration.nu (100%) rename {shell => crates/shell-use/shell}/shellIntegration.ps1 (100%) rename {shell => crates/shell-use/shell}/shellIntegration.xsh (100%) diff --git a/Cargo.lock b/Cargo.lock index 116c566..2b3f15f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,7 +871,6 @@ dependencies = [ "alacritty_terminal", "anyhow", "bitflags 2.13.1", - "clap", "compact_str", "dirs", "flate2", diff --git a/crates/shell-use-cli/Cargo.toml b/crates/shell-use-cli/Cargo.toml index 73dfb7e..7bb2482 100644 --- a/crates/shell-use-cli/Cargo.toml +++ b/crates/shell-use-cli/Cargo.toml @@ -20,4 +20,4 @@ dirs.workspace = true interprocess.workspace = true serde_json.workspace = true sha2.workspace = true -shell-use = { workspace = true, features = ["cli"] } +shell-use.workspace = true diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index 013673f..e7198d9 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -4,6 +4,36 @@ use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; use shell_use::protocol::TimeoutDefaults; use shell_use::shell::Shell; +#[derive(Clone, Copy, clap::ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum ShellArg { + Bash, + Powershell, + Pwsh, + Cmd, + Fish, + Zsh, + Xonsh, + Elvish, + Nushell, +} + +impl From for Shell { + fn from(shell: ShellArg) -> Self { + match shell { + ShellArg::Bash => Shell::Bash, + ShellArg::Powershell => Shell::Powershell, + ShellArg::Pwsh => Shell::Pwsh, + ShellArg::Cmd => Shell::Cmd, + ShellArg::Fish => Shell::Fish, + ShellArg::Zsh => Shell::Zsh, + ShellArg::Xonsh => Shell::Xonsh, + ShellArg::Elvish => Shell::Elvish, + ShellArg::Nushell => Shell::Nushell, + } + } +} + /// Per-class default timeouts for a session, in milliseconds. #[derive(Args, Clone, Copy, Default)] pub struct TimeoutArgs { @@ -63,7 +93,7 @@ pub enum Command { Open { /// Shell to launch (defaults to the platform shell). #[arg(long, value_enum)] - shell: Option, + shell: Option, /// Terminal width in columns. #[arg(long, default_value_t = DEFAULT_COLS)] cols: u16, @@ -319,6 +349,32 @@ mod tests { )); } + #[test] + fn open_shell_values_map_to_library_shells() { + let cases = [ + ("bash", Shell::Bash), + ("powershell", Shell::Powershell), + ("pwsh", Shell::Pwsh), + ("cmd", Shell::Cmd), + ("fish", Shell::Fish), + ("zsh", Shell::Zsh), + ("xonsh", Shell::Xonsh), + ("elvish", Shell::Elvish), + ("nushell", Shell::Nushell), + ]; + for (value, expected) in cases { + let cli = + Cli::try_parse_from(["shell-use", "open", "--shell", value]).expect("parse shell"); + let Some(Command::Open { + shell: Some(shell), .. + }) = cli.command + else { + panic!("expected Open with a shell"); + }; + assert_eq!(Shell::from(shell), expected); + } + } + #[test] fn run_accepts_readiness_flags() { let cli = diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index bbb60cd..554af0e 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -134,7 +134,7 @@ fn build_request(command: Command) -> anyhow::Result { no_wait_ready, timeouts, } => Request::Open { - shell, + shell: shell.map(Into::into), program: None, cols, rows, diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml index ba134d0..a805364 100644 --- a/crates/shell-use/Cargo.toml +++ b/crates/shell-use/Cargo.toml @@ -11,15 +11,10 @@ readme = "../../README.md" name = "shell_use" path = "src/lib.rs" -[features] -default = [] -cli = ["dep:clap"] - [dependencies] alacritty_terminal.workspace = true anyhow.workspace = true bitflags.workspace = true -clap = { workspace = true, optional = true } compact_str.workspace = true dirs.workspace = true flate2.workspace = true diff --git a/assets/nerd-fonts/LICENSE b/crates/shell-use/assets/nerd-fonts/LICENSE similarity index 100% rename from assets/nerd-fonts/LICENSE rename to crates/shell-use/assets/nerd-fonts/LICENSE diff --git a/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf b/crates/shell-use/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf similarity index 100% rename from assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf rename to crates/shell-use/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf diff --git a/shell/shellIntegration-env.zsh b/crates/shell-use/shell/shellIntegration-env.zsh similarity index 100% rename from shell/shellIntegration-env.zsh rename to crates/shell-use/shell/shellIntegration-env.zsh diff --git a/shell/shellIntegration-login.zsh b/crates/shell-use/shell/shellIntegration-login.zsh similarity index 100% rename from shell/shellIntegration-login.zsh rename to crates/shell-use/shell/shellIntegration-login.zsh diff --git a/shell/shellIntegration-profile.zsh b/crates/shell-use/shell/shellIntegration-profile.zsh similarity index 100% rename from shell/shellIntegration-profile.zsh rename to crates/shell-use/shell/shellIntegration-profile.zsh diff --git a/shell/shellIntegration-rc.zsh b/crates/shell-use/shell/shellIntegration-rc.zsh similarity index 100% rename from shell/shellIntegration-rc.zsh rename to crates/shell-use/shell/shellIntegration-rc.zsh diff --git a/shell/shellIntegration.bash b/crates/shell-use/shell/shellIntegration.bash similarity index 100% rename from shell/shellIntegration.bash rename to crates/shell-use/shell/shellIntegration.bash diff --git a/shell/shellIntegration.elv b/crates/shell-use/shell/shellIntegration.elv similarity index 100% rename from shell/shellIntegration.elv rename to crates/shell-use/shell/shellIntegration.elv diff --git a/shell/shellIntegration.fish b/crates/shell-use/shell/shellIntegration.fish similarity index 100% rename from shell/shellIntegration.fish rename to crates/shell-use/shell/shellIntegration.fish diff --git a/shell/shellIntegration.nu b/crates/shell-use/shell/shellIntegration.nu similarity index 100% rename from shell/shellIntegration.nu rename to crates/shell-use/shell/shellIntegration.nu diff --git a/shell/shellIntegration.ps1 b/crates/shell-use/shell/shellIntegration.ps1 similarity index 100% rename from shell/shellIntegration.ps1 rename to crates/shell-use/shell/shellIntegration.ps1 diff --git a/shell/shellIntegration.xsh b/crates/shell-use/shell/shellIntegration.xsh similarity index 100% rename from shell/shellIntegration.xsh rename to crates/shell-use/shell/shellIntegration.xsh diff --git a/crates/shell-use/src/render/nerd_font.rs b/crates/shell-use/src/render/nerd_font.rs index 14bf6b5..2ce5032 100644 --- a/crates/shell-use/src/render/nerd_font.rs +++ b/crates/shell-use/src/render/nerd_font.rs @@ -8,7 +8,7 @@ use crate::terminal::cell::EmuCell; // Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE. const FONT_DATA: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" + "/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" )); struct Glyph { @@ -260,7 +260,7 @@ mod tests { fn bundled_font_includes_its_mit_license() { let license = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../assets/nerd-fonts/LICENSE" + "/assets/nerd-fonts/LICENSE" )); assert!(license.starts_with("The MIT License (MIT)")); assert!(license.contains("Copyright (c) 2014 Ryan L McIntyre")); diff --git a/crates/shell-use/src/shell/mod.rs b/crates/shell-use/src/shell/mod.rs index a9147cf..dd2ac5c 100644 --- a/crates/shell-use/src/shell/mod.rs +++ b/crates/shell-use/src/shell/mod.rs @@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::config::home_dir; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] #[serde(rename_all = "lowercase")] -#[cfg_attr(feature = "cli", clap(rename_all = "lowercase"))] pub enum Shell { Bash, Powershell, @@ -69,43 +67,43 @@ pub fn write_integration_scripts() -> std::io::Result<()> { let files: &[(&str, &str)] = &[ ( "shellIntegration.bash", - include_str!("../../../../shell/shellIntegration.bash"), + include_str!("../../shell/shellIntegration.bash"), ), ( "shellIntegration.fish", - include_str!("../../../../shell/shellIntegration.fish"), + include_str!("../../shell/shellIntegration.fish"), ), ( "shellIntegration.ps1", - include_str!("../../../../shell/shellIntegration.ps1"), + include_str!("../../shell/shellIntegration.ps1"), ), ( "shellIntegration.xsh", - include_str!("../../../../shell/shellIntegration.xsh"), + include_str!("../../shell/shellIntegration.xsh"), ), ( "shellIntegration.elv", - include_str!("../../../../shell/shellIntegration.elv"), + include_str!("../../shell/shellIntegration.elv"), ), ( "shellIntegration.nu", - include_str!("../../../../shell/shellIntegration.nu"), + include_str!("../../shell/shellIntegration.nu"), ), ( "shellIntegration-rc.zsh", - include_str!("../../../../shell/shellIntegration-rc.zsh"), + include_str!("../../shell/shellIntegration-rc.zsh"), ), ( "shellIntegration-profile.zsh", - include_str!("../../../../shell/shellIntegration-profile.zsh"), + include_str!("../../shell/shellIntegration-profile.zsh"), ), ( "shellIntegration-env.zsh", - include_str!("../../../../shell/shellIntegration-env.zsh"), + include_str!("../../shell/shellIntegration-env.zsh"), ), ( "shellIntegration-login.zsh", - include_str!("../../../../shell/shellIntegration-login.zsh"), + include_str!("../../shell/shellIntegration-login.zsh"), ), ]; for (name, body) in files {