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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 15 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 23 additions & 18 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
23 changes: 23 additions & 0 deletions crates/shell-use-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
File renamed without changes.
64 changes: 60 additions & 4 deletions src/cli.rs → crates/shell-use-cli/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
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;

#[derive(Clone, Copy, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum ShellArg {
Bash,
Powershell,
Pwsh,
Cmd,
Fish,
Zsh,
Xonsh,
Elvish,
Nushell,
}

impl From<ShellArg> 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)]
Expand Down Expand Up @@ -63,7 +93,7 @@ pub enum Command {
Open {
/// Shell to launch (defaults to the platform shell).
#[arg(long, value_enum)]
shell: Option<Shell>,
shell: Option<ShellArg>,
/// Terminal width in columns.
#[arg(long, default_value_t = DEFAULT_COLS)]
cols: u16,
Expand Down Expand Up @@ -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 =
Expand Down
115 changes: 115 additions & 0 deletions crates/shell-use-cli/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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>) -> String {
explicit
.or_else(|| std::env::var("SHELL_USE_SESSION").ok())
.unwrap_or_else(|| "default".to_string())
}

pub fn session_was_specified(explicit: &Option<String>) -> 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}")),
);
}
}
Loading
Loading