diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1979e..14c7478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Forgent are documented here. Format based on [Keep a Chan ## [Unreleased] +### Added + +- `src-tauri/src/infrastructure/pty/shell_detector.rs` (T-226) — platform-aware shell detection per ARCHI §11.2. `pub fn detect() -> ShellSpec` returns the program + args that `PtyPool::spawn` will hand to `portable_pty::CommandBuilder`. Unix honours `$SHELL` and falls back to `/bin/bash` when the var is unset or empty; Windows prefers `pwsh` (PowerShell 7) over `powershell.exe` over `%COMSPEC%` / `cmd.exe`, with `-NoLogo` on the PowerShell variants. `ShellSpec` derives `Serialize + Deserialize + TS` (exported to `src/shared/types/ShellSpec.ts`) so it can flow through debug IPC later. The Windows priority chain is extracted into a pure `resolve_windows_shell(pwsh, powershell, comspec)` helper that is unit-tested on every platform — the Linux CI runners exercise the full pwsh > powershell > COMSPEC > cmd.exe fallback ladder without needing real PowerShell binaries. Unix env-var fallback covered by three `temp_env::with_var` tests (SHELL set, unset, empty). `which = "8.0.2"` added as a Windows-only target dependency via `cargo add --target 'cfg(windows)'` so Unix builds stay lean. 283/283 cargo test, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, and `cargo deny check` all green. + ## [0.1.0] - 2026-05-14 First minor release. Bundles every Sprint 2 deliverable (T-219 → T-224) plus the Sprint 1 follow-ups merged since `v0.0.2`. See per-task entries below for details. Highlights: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 802fc46..149c9be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -348,7 +348,7 @@ dependencies = [ "rustc-hash 1.1.0", "shlex", "syn 2.0.117", - "which", + "which 4.4.2", ] [[package]] @@ -1563,6 +1563,7 @@ dependencies = [ "tracing-subscriber", "ts-rs", "uuid", + "which 8.0.2", ] [[package]] @@ -6852,6 +6853,15 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c05c01e..7ee5523 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,3 +85,6 @@ pilot = ["dep:tauri-plugin-pilot"] # Auto-generated by `cargo add --optional`; kept as an alias so external # tooling that infers feature names from dep names continues to work. tauri-plugin-pilot = ["pilot"] + +[target."cfg(windows)".dependencies] +which = "8.0.2" diff --git a/src-tauri/src/infrastructure/pty/mod.rs b/src-tauri/src/infrastructure/pty/mod.rs index 9da6085..8b5e628 100644 --- a/src-tauri/src/infrastructure/pty/mod.rs +++ b/src-tauri/src/infrastructure/pty/mod.rs @@ -1 +1,3 @@ //! PTY — `portable-pty` pool, shell detection, stdin injection of Claude CLI commands. + +pub mod shell_detector; diff --git a/src-tauri/src/infrastructure/pty/shell_detector.rs b/src-tauri/src/infrastructure/pty/shell_detector.rs new file mode 100644 index 0000000..a84898b --- /dev/null +++ b/src-tauri/src/infrastructure/pty/shell_detector.rs @@ -0,0 +1,165 @@ +//! Shell detection — resolves the host shell that `PtyPool::spawn` will hand +//! to `portable_pty::CommandBuilder`. Unix honours `$SHELL` with `/bin/bash` +//! fallback; Windows prefers `pwsh` (PowerShell 7) over `powershell.exe` over +//! `%COMSPEC%` / `cmd.exe`. See ARCHI §11.2. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Resolved shell program plus the arguments needed to spawn it interactively. +/// +/// Forgent never invokes `claude` directly — it always spawns the user's +/// system shell (so `.bashrc`, `.zshrc`, aliases and `PATH` are honoured) and +/// then injects the Claude CLI command through stdin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/shared/types/")] +pub struct ShellSpec { + pub program: String, + pub args: Vec, +} + +/// Honour `$SHELL`; fall back to `/bin/bash` when the env var is unset or empty. +#[cfg(unix)] +pub fn detect() -> ShellSpec { + let program = std::env::var("SHELL") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "/bin/bash".into()); + ShellSpec { + program, + args: Vec::new(), + } +} + +/// Prefer `pwsh` (PowerShell 7) over `powershell.exe`, falling back to +/// `%COMSPEC%` and finally `cmd.exe` when neither PowerShell flavour is on PATH. +#[cfg(windows)] +pub fn detect() -> ShellSpec { + let comspec = std::env::var("COMSPEC").ok(); + resolve_windows_shell( + which::which("pwsh").is_ok(), + which::which("powershell").is_ok(), + comspec.as_deref(), + ) +} + +/// Pure resolution of the Windows shell priority chain. Extracted so the +/// fallback ladder can be exercised on Linux CI runners, which cannot resolve +/// `pwsh` / `powershell` through `which::which`. +#[cfg(any(test, windows))] +fn resolve_windows_shell( + pwsh_on_path: bool, + powershell_on_path: bool, + comspec: Option<&str>, +) -> ShellSpec { + if pwsh_on_path { + return ShellSpec { + program: "pwsh".into(), + args: vec!["-NoLogo".into()], + }; + } + if powershell_on_path { + return ShellSpec { + program: "powershell".into(), + args: vec!["-NoLogo".into()], + }; + } + let program = comspec + .filter(|value| !value.is_empty()) + .map(String::from) + .unwrap_or_else(|| "cmd.exe".into()); + ShellSpec { + program, + args: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod windows_resolver { + use super::*; + + #[test] + fn resolve_prefers_pwsh_when_available() { + let spec = resolve_windows_shell(true, true, Some("C:\\Windows\\System32\\cmd.exe")); + assert_eq!(spec.program, "pwsh"); + assert_eq!(spec.args, vec!["-NoLogo".to_string()]); + } + + #[test] + fn resolve_falls_back_to_powershell_when_pwsh_missing() { + let spec = resolve_windows_shell(false, true, Some("C:\\Windows\\System32\\cmd.exe")); + assert_eq!(spec.program, "powershell"); + assert_eq!(spec.args, vec!["-NoLogo".to_string()]); + } + + #[test] + fn resolve_uses_comspec_when_no_powershell_variants() { + let spec = resolve_windows_shell(false, false, Some("D:\\custom\\cmd.exe")); + assert_eq!(spec.program, "D:\\custom\\cmd.exe"); + assert!(spec.args.is_empty()); + } + + #[test] + fn resolve_falls_back_to_cmd_when_comspec_unset() { + let spec = resolve_windows_shell(false, false, None); + assert_eq!(spec.program, "cmd.exe"); + assert!(spec.args.is_empty()); + } + + #[test] + fn resolve_falls_back_to_cmd_when_comspec_empty() { + let spec = resolve_windows_shell(false, false, Some("")); + assert_eq!(spec.program, "cmd.exe"); + assert!(spec.args.is_empty()); + } + } + + #[cfg(unix)] + mod unix { + use super::*; + + #[test] + fn detect_uses_shell_env_var_when_set() { + temp_env::with_var("SHELL", Some("/usr/bin/zsh"), || { + let spec = detect(); + assert_eq!(spec.program, "/usr/bin/zsh"); + assert!(spec.args.is_empty()); + }); + } + + #[test] + fn detect_falls_back_to_bin_bash_when_shell_unset() { + temp_env::with_var("SHELL", None::<&str>, || { + let spec = detect(); + assert_eq!(spec.program, "/bin/bash"); + assert!(spec.args.is_empty()); + }); + } + + #[test] + fn detect_falls_back_to_bin_bash_when_shell_empty() { + temp_env::with_var("SHELL", Some(""), || { + let spec = detect(); + assert_eq!(spec.program, "/bin/bash"); + assert!(spec.args.is_empty()); + }); + } + } + + #[cfg(windows)] + mod windows { + use super::*; + + #[test] + fn detect_returns_non_empty_program_on_windows_runner() { + let spec = detect(); + assert!( + !spec.program.is_empty(), + "detect() must always return a usable program" + ); + } + } +} diff --git a/src/shared/types/ShellSpec.ts b/src/shared/types/ShellSpec.ts new file mode 100644 index 0000000..44ae534 --- /dev/null +++ b/src/shared/types/ShellSpec.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Resolved shell program plus the arguments needed to spawn it interactively. + * + * Forgent never invokes `claude` directly — it always spawns the user's + * system shell (so `.bashrc`, `.zshrc`, aliases and `PATH` are honoured) and + * then injects the Claude CLI command through stdin. + */ +export type ShellSpec = { program: string, args: Array, };