Skip to content
Open
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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion src-tauri/Cargo.lock

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

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions src-tauri/src/infrastructure/pty/mod.rs
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;
165 changes: 165 additions & 0 deletions src-tauri/src/infrastructure/pty/shell_detector.rs
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());
Comment on lines +24 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat whitespace-only env vars as empty before fallback resolution.

SHELL=" " or COMSPEC=" " 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/infrastructure/pty/shell_detector.rs` around lines 24 - 27, The
env var handling for program resolution (e.g., the std::env::var("SHELL") block
that assigns program) should treat whitespace-only values as empty; change the
chain to trim the retrieved string before checking emptiness (for example, map
or filter using value.trim()) so SHELL="   " won't be accepted and the fallback
("/bin/bash") will be used; apply the same trim-before-empty-check fix to the
COMSPEC handling at the other occurrence (lines referenced around the COMSPEC
resolution) so both std::env::var("SHELL") and std::env::var("COMSPEC") treat
whitespace-only values as empty.

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"
);
}
}
}
10 changes: 10 additions & 0 deletions src/shared/types/ShellSpec.ts
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>, };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Regenerate shared type barrel after adding ShellSpec

Adding ShellSpec.ts without updating src/shared/types/index.ts introduces deterministic codegen drift: scripts/codegen-types.ts rebuilds the barrel from all *.ts files in that directory, so it will add export type * from "./ShellSpec"; on the next pnpm run codegen. The CI codegen-diff gate in .github/workflows/ci.yml checks for exactly this drift and will fail even if runtime code is unchanged.

Useful? React with 👍 / 👎.

Loading