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
137 changes: 133 additions & 4 deletions docs/adding-executors.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,10 @@ pub trait AiCliExecutor: Send + Sync {
/// Returns the CLI command name used by this executor.
fn command(&self) -> &'static str;

/// Checks if this executor's CLI tool is available in PATH.
/// Default implementation uses `which <command>`.
async fn is_available(&self) -> bool {
check_cli_available(self.command()).await.unwrap_or(false)
/// Checks if this executor's CLI tool is available.
/// Default implementation uses the shell-aware resolution strategy.
fn is_available(&self) -> bool {
check_cli_available(self.command())
}
}
```
Expand Down Expand Up @@ -244,3 +244,132 @@ pub struct ClaudeExecutor;
- Use `&'static str` for `name()` and `command()` to avoid allocations
- The `run_cli_with_output()` helper handles process spawning, output streaming, and cleanup
- On Linux, child processes are automatically killed when the parent dies (via `PR_SET_PDEATHSIG`)

## CLI Availability Resolution Strategy

This section documents the cross-shell strategy for resolving CLI commands (codex, claude, gemini) that accounts for PATH, shell aliases/functions, and executability without introducing shell injection risk.

### Problem Statement

The basic `which`/`where` approach only searches PATH and misses commands that are available via:
- Shell aliases (e.g., `alias claude='/usr/local/bin/claude-cli'`)
- Shell functions (e.g., `claude() { /path/to/claude "$@"; }`)
- PATH modifications in shell profiles (e.g., `~/.zshrc`, `~/.bashrc`)

This is particularly common on macOS where tools installed via Homebrew or npm may only be accessible after shell profile initialization.

### Resolution Algorithm

The CLI resolution follows this priority order:

1. **Direct PATH lookup** (fast path): Check if the command exists directly in PATH using `which`/`where`. If found and executable, use it.

2. **Shell-based resolution** (fallback): If not found in PATH, invoke the user's shell to resolve the command:
```
$SHELL -l -i -c "command -v <cmd>"
```
- `-l`: Login shell (loads `~/.profile`, `~/.bash_profile`, `~/.zprofile`)
- `-i`: Interactive shell (loads `~/.bashrc`, `~/.zshrc`)
- `command -v`: POSIX-compliant way to resolve commands (preferred over `type` or `which`)

3. **Output classification**: Parse the output of `command -v` to determine the command type:
- **Path** (e.g., `/usr/local/bin/claude`): Direct executable in PATH
- **Alias** (e.g., `alias claude='...'`): Shell alias definition
- **Function** (e.g., `claude is a function`): Shell function
- **Builtin** (e.g., `builtin`): Shell builtin command
- **Not found** (empty output or error): Command unavailable

### Shell Invocation Behavior by Shell Type

| Shell | Login Profile | Interactive Profile | `command -v` Support |
|-------|---------------|---------------------|----------------------|
| bash | `~/.bash_profile` or `~/.profile` | `~/.bashrc` | Yes (POSIX) |
| zsh | `~/.zprofile` | `~/.zshrc` | Yes (POSIX) |
| sh | `~/.profile` | - | Yes (POSIX) |
| fish | `~/.config/fish/config.fish` | Same | Yes (via `-v` alias) |

**Note**: Fish shell supports `command -v` as an alias for `command --search`, which prints the path to the external command. The implementation uses `command -v` universally across all shells.

### Executability Verification

After resolving the command path, verify executability:

1. **For PATH executables**: Check that the resolved path:
- Exists as a regular file (not directory or symlink to directory)
- Has execute permission for the current user

2. **For aliases/functions**: The command is considered available but cannot be verified for executability until actual invocation. Mark as "available via shell".

3. **For builtins**: Shell builtins are always executable within their shell context.

### Classification Rules

Commands are classified into these categories for UI display and execution strategy:

| Classification | Resolution Output | Execution Method | UI Indicator |
|---------------|-------------------|------------------|--------------|
| `PathExecutable` | Absolute path | `Command::new(path)` | ✓ Available |
| `ShellAlias` | `alias name='...'` | Shell invocation required | ⚡ Available (alias) |
| `ShellFunction` | Function definition | Shell invocation required | ⚡ Available (function) |
| `ShellBuiltin` | `builtin` | Shell invocation required | ⚡ Available (builtin) |
| `NotFound` | Empty/error | N/A | ✗ Not found |

### Security Considerations

**Command Name Validation** (Critical):
- Command names MUST be validated before shell invocation
- Allow only: alphanumeric characters, hyphens (`-`), underscores (`_`)
- Reject any command containing: spaces, quotes, semicolons, pipes, redirects, backticks, `$`, etc.
- Maximum length: 64 characters

**Safe validation regex**: `^[a-zA-Z0-9_-]{1,64}$`

**Shell Injection Prevention**:
- Never interpolate untrusted input into shell commands
- Use the validated command name directly with `command -v`
- Do not use shell expansion or eval

**Example safe invocation**:
```rust
// SAFE: command_name is pre-validated to match ^[a-zA-Z0-9_-]{1,64}$
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
Command::new(&shell)
.args(["-l", "-i", "-c", &format!("command -v {}", command_name)])
.output()
```

### Execution Strategy

Based on the classification, choose the execution method:

1. **PathExecutable**: Execute directly with `Command::new(resolved_path)`
- Most reliable and fastest
- Inherits environment from McGravity process

2. **ShellAlias/ShellFunction/ShellBuiltin**: Execute via shell wrapper
```rust
Command::new(&shell)
.args(["-l", "-i", "-c", &format!("{} {}", command_name, escaped_args)])
```
- Required for alias/function resolution
- Slower due to shell startup overhead
- Args must be properly escaped for shell

### Alignment with Trait-Based Executor Abstraction

Per `CLAUDE.md` "Trait-Based Executor Abstraction" and `docs/architecture.md`:

- The `AiCliExecutor::is_available()` method should use the resolution algorithm above
- The `AiCliExecutor::command()` returns the command name (e.g., "claude")
- Execution via `execute()` should use the appropriate execution strategy based on classification
- The resolution result can be cached at startup to avoid repeated shell invocations

### CI Pipeline Compliance

Per `.github/workflows/ci.yaml`, the implementation must:
- Pass `cargo fmt --all -- --check`
- Pass `cargo clippy --all-targets --all-features -- -D warnings`
- Pass `cargo build --locked --all-features`
- Pass `cargo test --locked --all-features`

The shell-based resolution should be implemented behind a feature flag or as a fallback to maintain fast CI execution where shell environment is minimal.
4 changes: 2 additions & 2 deletions src/app/render/initial_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,13 @@ impl App {
/// Renders an error line for an unavailable CLI.
///
/// This is used by both the initial setup modal and the settings panel
/// to display a consistent error message when a CLI tool is not found.
/// to display a consistent error message when a CLI tool is unavailable.
pub(crate) fn render_unavailable_error(&self, model: Model) -> Line<'static> {
let command = model.command();
Line::from(vec![
Span::raw(" "), // Indent to align with model field
Span::styled(
format!("⚠ `{command}` command not found in PATH"),
format!("⚠ `{command}` is not available or not executable"),
self.theme.error_style(),
),
])
Expand Down
4 changes: 2 additions & 2 deletions src/app/tests/initial_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,10 +1007,10 @@ mod render_tests {
"│ │Select your default AI CLI tools. │ │",
"│ │ │ │",
"│ │› Planning Model [Codex] │ │",
"│ │ ⚠ `codex` command not found in PATH │ │",
"│ │ ⚠ `codex` is not available or not executable │ │",
"│ │ │ │",
"│ │ Execution Model [Codex] │ │",
"│ │ ⚠ `codex` command not found in PATH │ │",
"│ │ ⚠ `codex` is not available or not executable │ │",
"│ │ │ │",
"│ │ │ │",
"│ │ │ │",
Expand Down
Loading