-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(infra): T-226 shell detector for $SHELL / pwsh / cmd.exe (closes #121) #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| //! PTY — `portable-pty` pool, shell detection, stdin injection of Claude CLI commands. | ||
|
|
||
| pub mod shell_detector; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>, | ||
| } | ||
|
|
||
| /// 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" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>, }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding Useful? React with 👍 / 👎. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Treat whitespace-only env vars as empty before fallback resolution.
SHELL=" "orCOMSPEC=" "currently bypasses the empty check and can return an unusable program value. Trim first, then apply the emptiness fallback.💡 Suggested patch
pub fn detect() -> ShellSpec { let program = std::env::var("SHELL") .ok() - .filter(|value| !value.is_empty()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) .unwrap_or_else(|| "/bin/bash".into()); ShellSpec { program, args: Vec::new(), @@ let program = comspec - .filter(|value| !value.is_empty()) + .map(str::trim) + .filter(|value| !value.is_empty()) .map(String::from) .unwrap_or_else(|| "cmd.exe".into());Also applies to: 67-69
🤖 Prompt for AI Agents