diff --git a/.gitignore b/.gitignore
index 84483ac12e..364b8ecf03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,4 +74,3 @@ sources/x86_64-unknown-redox/
sources/*.tar.gz
Packages/*.pkgar
.slim/deepwork/
-local/recipes/shells/brush/source
diff --git a/local/recipes/shells/brush/source/.cargo/config.toml b/local/recipes/shells/brush/source/.cargo/config.toml
new file mode 100644
index 0000000000..6e8d95a159
--- /dev/null
+++ b/local/recipes/shells/brush/source/.cargo/config.toml
@@ -0,0 +1,10 @@
+[alias]
+xtask = "run --package xtask --"
+
+[target.wasm32-unknown-unknown]
+# Select a getrandom backend that will work for this target
+rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
+
+[target.wasm32-wasip2]
+# Uninteresting flag to ensure this overrides the flags in [build]
+rustflags = ["--cfg", "x"]
diff --git a/local/recipes/shells/brush/source/.cliffignore b/local/recipes/shells/brush/source/.cliffignore
new file mode 100644
index 0000000000..fafa76fa46
--- /dev/null
+++ b/local/recipes/shells/brush/source/.cliffignore
@@ -0,0 +1,9 @@
+# Commits not to include in changelog
+
+a8ba7982d9ca3746cd6f6e3c26783e9d1d765466
+
+448e60c51cb721bd6991c00bc39e926c7a1ffdac
+6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19
+5400c8267db15ed56b5758a6fc35667678a5c856
+87ad64f9ae2044d1aa70effd50244491a765c33e
+6567ede8f0a9be622176dc5e840e5b93344aeb15
diff --git a/local/recipes/shells/brush/source/.config/nextest.toml b/local/recipes/shells/brush/source/.config/nextest.toml
new file mode 100644
index 0000000000..34500a2aef
--- /dev/null
+++ b/local/recipes/shells/brush/source/.config/nextest.toml
@@ -0,0 +1,8 @@
+[profile.default]
+retries = 3
+status-level = "slow"
+slow-timeout = { period = "5s", terminate-after = 3, grace-period = "2s" }
+fail-fast = false
+
+[profile.default.junit]
+path = "test-results.xml"
diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml
new file mode 100644
index 0000000000..e0fd2132ae
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml
@@ -0,0 +1,86 @@
+name: Compatibility Bug Report
+description: Report a case where brush behaves differently from bash
+labels: ["bug", "area: compat"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for helping improve brush's compatibility!
+
+ This template is for reporting cases where brush produces different behavior than bash for the same input. Before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if this has already been reported.
+
+ - type: input
+ id: brush-version
+ attributes:
+ label: brush version
+ description: The version of brush you're using. You can find this with `brush --version`.
+ placeholder: "e.g., brush 0.2.9"
+ validations:
+ required: true
+
+ - type: input
+ id: bash-version
+ attributes:
+ label: bash version (for comparison)
+ description: The version of bash you compared against, if applicable. You can find this with `bash --version`.
+ placeholder: "e.g., GNU bash, version 5.2.21(1)-release"
+ validations:
+ required: false
+
+ - type: dropdown
+ id: platform
+ attributes:
+ label: Platform / OS
+ options:
+ - Linux
+ - macOS
+ - WSL
+ - Windows
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Steps to reproduce
+ description: >
+ Provide the shell commands, script, or other usage details that
+ reproduced the issue. If you can narrow it down to a minimal
+ reproducing example, that's very helpful but not required.
+ placeholder: |
+ # Example:
+ brush -c 'some command'
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual-behavior
+ attributes:
+ label: Actual behavior (brush)
+ description: What did brush do in this case?
+ placeholder: Describe or paste the brush output...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: Expected behavior
+ description: >
+ What did you expect to happen? If relevant, what does bash or another
+ comparable shell do in this case?
+ placeholder: Describe or paste the expected output or behavior
+ validations:
+ required: true
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: >
+ Is there anything else you think is important to share? You might consider
+ including additional environment specifics (e.g., relevant .bashrc config),
+ workarounds, related issues, any additional debugging you've performed, etc.
+ validations:
+ required: false
diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..83e764702d
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions & Discussion
+ url: https://github.com/reubeno/brush/discussions
+ about: Ask questions, share ideas, or discuss brush in GitHub Discussions.
diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000000..7a3283fadf
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,52 @@
+name: Feature Request
+description: Suggest a new feature or enhancement for brush
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to suggest an improvement to brush! We welcome and appreciate all input and feedback. While we can't commit to prioritizing or implementing all such requests, we'd rather you make the suggestion than not.
+
+ Our only requests: (1) please ensure you follow our project's [Code of Conduct](https://github.com/reubeno/brush#coc-ov-file), and (2) before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if a similar request already exists. If it does, feel free to add a comment to the existing issue instead.
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Feature description
+ description: A clear and concise description of the feature or enhancement you'd like.
+ placeholder: Describe the feature...
+ validations:
+ required: true
+
+ - type: textarea
+ id: use-case
+ attributes:
+ label: Use case / motivation
+ description: |
+ Why is this feature important to you? What problem does it solve? How would you use it?
+
+ Providing context helps us understand the value and prioritize accordingly.
+ placeholder: Explain why this feature would be useful...
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposed-solution
+ attributes:
+ label: Proposed solution
+ description: >
+ If you have ideas on how this could be implemented or how it should work,
+ share them here. Note that this is purely optional.
+ placeholder: Describe how you think this could work...
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: >
+ Any other context, references, or screenshots that would help us understand your request.
+ For example, links to relevant documentation, other shells that implement this, etc.
+ validations:
+ required: false
diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml
new file mode 100644
index 0000000000..cf0010277f
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml
@@ -0,0 +1,19 @@
+name: Other Issue
+description: "Report an issue that doesn't fit the other templates"
+labels: ["state: triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ If your issue is a **feature request**, please use the [Feature Request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) template instead. If it's about **brush behaving differently from bash** or another compatibility issue, please use the [Compatibility Bug Report](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml) template instead.
+
+ For general questions or discussion, consider using [GitHub Discussions](https://github.com/reubeno/brush/discussions).
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please describe the issue in detail.
+ placeholder: Describe the issue...
+ validations:
+ required: true
diff --git a/local/recipes/shells/brush/source/.github/copilot-instructions.md b/local/recipes/shells/brush/source/.github/copilot-instructions.md
new file mode 100644
index 0000000000..2fc777fe66
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/copilot-instructions.md
@@ -0,0 +1,394 @@
+# GitHub Copilot Coding Agent Instructions for brush
+
+## Project Overview
+
+**brush** (Bourne Rusty Shell) is a POSIX- and bash-compatible shell implemented in Rust. It's a multi-crate workspace (~60K lines of Rust code) targeting Linux, macOS, and WSL, with experimental Windows and WASM support. The project emphasizes compatibility testing against bash as an oracle.
+
+**Key Stats:** Rust 2024 edition, MSRV 1.88.0, 5 main crates, 1500+ compatibility test cases, published to crates.io.
+
+## Critical: Read AGENTS.md First
+
+**BEFORE making any changes, read `/AGENTS.md`.** It contains detailed architecture patterns, testing workflows, and development guidelines specific to this project. The information below supplements (not replaces) AGENTS.md.
+
+## Code Review Checklist
+
+When reviewing PRs, verify:
+
+- [ ] **Documentation**: All exported APIs have rustdoc comments (missing docs = CI failure)
+- [ ] **Forbidden patterns**: No `panic`, `unwrap_in_result`, `expect_used`, or `todo` (all denied by clippy)
+- [ ] **Error handling**: Uses `thiserror` for crate errors; `anyhow` only in tests
+- [ ] **Logging**: Uses `tracing::debug!(target: trace_categories::CATEGORY, "msg")` pattern
+- [ ] **Testing**: Compatibility fixes include YAML test cases in `brush-shell/tests/cases/`
+- [ ] **Testing**: Builtin changes have tests in `brush-shell/tests/cases/builtin/`
+- [ ] **Testing**: Unit tests expected for new public APIs (when feasible) (see AGENTS.md section 2)
+- [ ] **Platform code**: Platform-specific code is in `brush-core/src/sys/` modules
+- [ ] **Breaking changes**: Public API changes are clearly highlighted and documented
+- [ ] **Builder pattern**: Configuration uses builder pattern (see `Shell::builder()`)
+- [ ] **Code quality**: Passes `cargo fmt --check` and `cargo clippy` without warnings
+- [ ] **Commit format**: Follows [Conventional Commits](https://www.conventionalcommits.org/) (feat:, fix:, docs:, test:)
+- [ ] **Dependencies**: No unnecessary cloning (use references when possible)
+- [ ] **Cross-platform**: Uses appropriate `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")`
+
+## Workspace Structure
+
+```
+brush/
+├── brush-shell/ # CLI application & main entry point
+├── brush-interactive/ # Interactive shell (readline, completion)
+├── brush-core/ # Core shell runtime & builtins
+├── brush-builtins/ # Shell builtin implementations
+├── brush-parser/ # AST generation & parsing
+├── xtask/ # Build automation tasks
+└── docs/ # Diátaxis-structured documentation
+```
+
+**Dependency flow:** brush-shell → brush-interactive → brush-core → brush-parser
+ ↘ brush-builtins ↗
+
+## Build & Validation Commands
+
+### Terminal Command Execution
+
+When running commands that may take more than a few seconds (cargo build, cargo check, cargo test, cargo clippy, cargo xtask, etc.), **run them in background mode and poll for results** rather than blocking. This prevents commands from being cancelled due to timeouts. Use `isBackground: true` with `run_in_terminal`, then use `get_terminal_output` to check results.
+
+### Using xtask (Recommended)
+
+The project provides a `cargo xtask` command that centralizes common development tasks. This is the recommended approach for running checks and tests.
+
+#### Quick Development Cycle
+
+```bash
+# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
+cargo xtask ci quick
+
+# Run full pre-commit checks (~45s warm): quick + deps, schemas, integration tests
+cargo xtask ci pre-commit
+
+# Run with --continue-on-error to see all failures at once
+cargo xtask ci pre-commit -k
+
+# Add -v for verbose output showing exact commands being run
+cargo xtask -v ci pre-commit
+```
+
+#### Individual Checks
+
+```bash
+# Format check
+cargo xtask check fmt
+
+# Lint check (clippy)
+cargo xtask check lint
+
+# Dependency check (cargo-deny)
+cargo xtask check deps
+
+# Build check
+cargo xtask check build
+
+# Schema check (regenerates and diffs)
+cargo xtask check schemas
+```
+
+#### Running Tests
+
+```bash
+# Run unit tests (fast tests excluding integration binaries)
+cargo xtask test unit
+
+# Run integration tests (all workspace tests including compat tests)
+cargo xtask test integration
+
+# Run tests with coverage
+cargo xtask test integration --coverage --coverage-output codecov.xml
+```
+
+### Manual Approach (Alternate)
+
+For finer-grained control or when xtask isn't available:
+
+#### Quick Development Cycle (Use These Frequently)
+
+```bash
+# Fast syntax/type checking (< 5 seconds)
+cargo check --workspace
+
+# Package-specific checking (even faster)
+cargo check --package brush-core
+
+# Format code (ALWAYS run before committing)
+cargo fmt --all
+
+# Lint code (ALWAYS run before committing)
+cargo clippy --workspace --all-features --all-targets
+
+# Run package-specific tests (fast iteration)
+cargo test --package brush-parser
+cargo test --package brush-core
+```
+
+**Note:** `cargo fmt --check` may show warnings about unstable rustfmt features (`wrap_comments`, `comment_width`) on stable Rust. These are harmless and expected.
+
+### Comprehensive Testing Workflow
+
+Follow this **exact order** for efficient testing:
+
+1. **Inner loop** (during development):
+ ```bash
+ cargo check --package
+ cargo test --package
+ ```
+
+2. **Compatibility tests** (critical for shell behavior):
+ ```bash
+ cargo test --test brush-compat-tests
+
+ # Run specific test case:
+ cargo test --test brush-compat-tests -- 'builtin/echo'
+ ```
+
+3. **Full workspace tests** (before considering work complete):
+ ```bash
+ cargo test --workspace
+ ```
+
+**Test timing:** Package tests: 3-20 seconds. Compat tests: ~18 seconds build + test time. Full workspace: several minutes.
+
+### Pre-Commit Validation (Before Every Commit)
+
+**Recommended:** Run the xtask pre-commit workflow:
+
+```bash
+cargo xtask ci pre-commit
+```
+
+**Manual approach:** Run these before every commit:
+
+```bash
+cargo fmt --check --all
+cargo clippy --workspace --all-features --all-targets
+```
+
+### Pre-PR Validation (Before Opening Pull Request)
+
+**Recommended:** Run pre-commit checks which includes full test suite:
+
+```bash
+cargo xtask ci pre-commit
+```
+
+**Manual approach:** In addition to pre-commit checks, also run:
+
+```bash
+cargo test --workspace
+```
+
+### Pre-Finish Quality Gates (Run Before Completing Task)
+
+**Recommended:** Run the xtask pre-commit workflow which covers all essential checks:
+
+```bash
+cargo xtask ci pre-commit
+```
+
+**Manual approach:**
+
+```bash
+cargo test --test brush-compat-tests
+cargo deny check all # License/security audit (run LAST, not frequently)
+cargo clippy --workspace --all-features --all-targets
+cargo fmt --check --all
+cargo test --workspace
+```
+
+**Timing note:** `cargo deny check all` takes ~1-5 seconds. Only run as final validation step.
+
+### Build Variants
+
+```bash
+# Standard debug build
+cargo build
+
+# Release build (takes ~2+ minutes, avoid during iteration)
+cargo build --release
+
+# Check all targets and features
+cargo check --all-features --all-targets
+```
+
+## Testing Philosophy
+
+**Test-driven approach:** When fixing bugs or adding features, write test cases in `brush-shell/tests/cases/*.yaml` BEFORE implementation. Use these to validate your changes.
+
+**Integration test structure:** Tests are YAML-based, run shell commands, compare stdout/stderr/exit codes against bash oracle. See `docs/reference/integration-testing.md` and AGENTS.md section 2 for detailed testing strategy.
+
+**Test categories:**
+- Unit tests: In-file with `#[cfg(test)]`
+- Integration tests: `brush-shell/tests/` directory
+- Compatibility tests: YAML cases in `brush-shell/tests/cases/`
+- Benchmarks: `brush-shell/benches/` and crate-level `benches/`
+
+## Common Pitfalls & Solutions
+
+### ❌ Don't Do This
+- Run full test suite on every change (too slow)
+- Skip `cargo fmt` and `cargo clippy` before committing
+- Use `cargo deny check` during development iteration
+- Clone values unnecessarily (use references)
+- Add breaking changes to public APIs without highlighting them
+- Forget to add compat test cases for compatibility fixes
+
+### ✅ Do This
+- Target specific packages/tests during development
+- Run fmt/clippy before every commit
+- Follow builder pattern for configuration (see `Shell::builder()`)
+- Keep platform-specific code in `brush-core/src/sys/`
+- Document all exported APIs with rustdoc
+- Use `tracing::debug!(target: trace_categories::CATEGORY, "msg")` for logging
+- Add test cases to `brush-shell/tests/cases/` for compatibility changes
+
+## Error Handling & Logging
+
+```rust
+// Use thiserror for crate-specific errors
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum MyError { ... }
+
+// Use anyhow ONLY in tests
+#[cfg(test)]
+use anyhow::Result;
+
+// Logging with trace categories
+use crate::trace_categories;
+tracing::debug!(target: trace_categories::COMMANDS, "executing: {}", cmd);
+```
+
+**Available trace categories:** COMMANDS, COMPLETION, EXPANSION, FUNCTIONS, INPUT, JOBS, PARSE, PATTERN, UNIMPLEMENTED
+
+## Linting Configuration
+
+The project uses **extremely strict** linting (workspace-level in `Cargo.toml`):
+- All Rust warnings denied
+- All clippy warnings denied (pedantic, cargo, nursery, perf)
+- `expect_used`, `panic`, `todo`, `unwrap_in_result` are **forbidden**
+- Missing docs on exported items are errors
+
+**Your code MUST pass `cargo clippy` without warnings.**
+
+## Cross-Platform Considerations
+
+- Primary targets: Linux (x86_64, aarch64), macOS (aarch64)
+- Secondary: Windows (x86_64), WASM (wasm32-unknown-unknown, wasm32-wasip2)
+- Platform-specific code goes in `brush-core/src/sys/` modules
+- Use `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")` appropriately
+- See `.cargo/config.toml` for target-specific configurations
+
+## CI Pipeline (What Will Run on Your PR)
+
+GitHub Actions runs these checks (from `.github/workflows/ci.yaml`):
+
+1. **Build** on multiple platforms (x86_64/aarch64 Linux, macOS, Windows, WASM)
+2. **Tests** on Linux x86_64, Linux aarch64, macOS
+3. **Static checks** (format, clippy, cargo-deny) on stable + MSRV (1.88.0)
+4. **Compatibility tests** with bash as oracle
+5. **Code coverage** reports (70% overall threshold, no 5% negative delta)
+6. **External test suites** (bash-completion test suite)
+7. **OS compatibility** (Arch, Debian, Fedora, NixOS, openSUSE)
+8. **Benchmarks** (performance regression detection on PRs)
+9. **Public API analysis** (breaking change detection)
+
+**All of these must pass for PR to merge.**
+
+## Making Changes
+
+### Editing Core Shell Behavior
+1. Check `brush-core/src/shell.rs` for `Shell` struct
+2. Use `Shell::builder()` for construction
+3. Update `brush-shell/src/main.rs` if CLI changes needed
+
+### Adding/Modifying Builtins
+1. Edit files in `brush-builtins/src/`
+2. Register in `brush-builtins/src/factory.rs`
+3. Add test cases in `brush-shell/tests/cases/builtin/`
+
+### Parser Changes
+1. Modify `brush-parser/src/`
+2. Update AST definitions
+3. Test with `cargo test --package brush-parser`
+
+### Breaking Changes Policy
+- Avoid breaking public APIs (all crate exports are public)
+- If unavoidable, highlight clearly and document thoroughly
+- New optional fields on public structs are OK if struct implements `Default`
+- See AGENTS.md section 3 for complete breaking change policy
+
+## Performance & Benchmarking
+
+```bash
+# Run benchmarks (using xtask)
+cargo xtask analyze bench
+
+# Run benchmarks with output file
+cargo xtask analyze bench --output benchmarks.txt
+
+# Run benchmarks (manual)
+cargo bench --workspace --benches
+
+# Collect flamegraphs (10 second profiling)
+cargo bench --workspace --benches -- --profile-time 10
+# Output: target/criterion//profile/*.svg
+```
+
+**Note:** Performance regression testing runs automatically on PRs. Don't worry about it unless working on performance-specific features.
+
+## Documentation Standards
+
+**Rustdoc:** REQUIRED for all exported types, functions, traits, modules. Missing docs = CI failure.
+**Examples:** Only needed for major feature additions.
+**Style:** Follow Rust documentation best practices.
+
+## Commit Messages
+
+Use [Conventional Commits](https://www.conventionalcommits.org/):
+```
+feat: add support for X
+fix: correct behavior of Y
+docs: update Z documentation
+test: add test cases for W
+```
+
+## AI-Assisted Contributions
+
+If using AI assistance significantly, add to PR description or commit message:
+```
+Assisted-by: GitHub Copilot
+```
+
+## When Something Fails
+
+1. **Test failures:** Focus on affected area first, check if new tests are needed
+2. **Format/clippy failures:** Fix immediately before proceeding
+3. **Compat test failures:** Indicates shell behavior change, may need test updates
+4. **Build failures:** Check dependencies, verify Rust version (1.88.0+)
+5. **Timeout issues:** Build from scratch can take 2+ minutes
+
+## Quick Reference
+
+| Command | When | Time |
+|---------|------|------|
+| `cargo xtask ci quick` | Rapid iteration (inner loop) | ~7s |
+| `cargo xtask ci pre-commit` | Before commit (comprehensive) | ~45s |
+| `cargo xtask ci pre-commit -k` | See all failures at once | ~45s |
+| `cargo check` | Constantly during dev | ~3-5s |
+| `cargo test --package X` | After each change | 3-20s |
+| `cargo xtask test unit` | Fast unit tests | ~4.5s |
+| `cargo xtask test integration` | All workspace tests | ~36s |
+| `cargo xtask check fmt` | Before every commit | <1s |
+| `cargo xtask check lint` | Before commit | ~5-10s |
+| `cargo xtask check deps` | Final validation only | ~1-5s |
+
+## Trust These Instructions
+
+Only search the codebase if information here or in AGENTS.md is incomplete, contradictory, or proven incorrect. These instructions are validated against the actual working repository.
diff --git a/local/recipes/shells/brush/source/.github/dependabot.yml b/local/recipes/shells/brush/source/.github/dependabot.yml
new file mode 100644
index 0000000000..20f5531396
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/dependabot.yml
@@ -0,0 +1,23 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ cooldown:
+ default-days: 7
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "cargo"
+ cooldown:
+ default-days: 7
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ cargo:
+ patterns:
+ - "*"
diff --git a/local/recipes/shells/brush/source/.github/workflows/cd.yaml b/local/recipes/shells/brush/source/.github/workflows/cd.yaml
new file mode 100644
index 0000000000..28a5b19073
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/cd.yaml
@@ -0,0 +1,203 @@
+#
+# Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml
+#
+
+name: CD # Continuous Deployment
+run-name: CD${{ github.event_name == 'release' && ' (release)' || ' (dry run)' }}
+
+on:
+ release:
+ types: [published]
+
+ # Manual triggers don't actually publish but dry-run the builds.
+ workflow_dispatch: null
+
+ # Run on PR in dry-run mode to make sure this workflow is still generally
+ # working.
+ pull_request:
+ branches: ["main"]
+
+ # Run on pushes into `main` as a way to have "nightly"-ish binaries.
+ push:
+ branches: ["main"]
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_INCREMENTAL: 0
+ CARGO_NET_GIT_FETCH_WITH_CLI: true
+ CARGO_NET_RETRY: 10
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+ RUSTFLAGS: -D warnings
+ RUSTUP_MAX_RETRIES: 10
+
+defaults:
+ run:
+ shell: bash
+
+permissions: {}
+
+jobs:
+ upload-docs:
+ name: "Generate and upload documentation"
+
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+
+ steps:
+ - name: "Checkout repository"
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: "Install Rust toolchain"
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: stable
+
+ - name: "Enable cargo cache"
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+
+ - name: "Generate documentation distribution"
+ run: cargo xtask gen docs dist --out brush-docs.tar.gz
+
+ - name: "Upload documentation to release"
+ if: github.event_name == 'release'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ gh release upload ${GITHUB_RELEASE_TAG} \
+ brush-docs.tar.gz \
+ brush-docs.tar.gz.sha256 \
+ brush-docs.tar.gz.sha512
+
+ - name: "Generate artifact attestation"
+ if: github.event_name == 'release'
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: "brush-docs.tar.gz*"
+
+ - name: "Upload documentation artifact (dry-run)"
+ if: github.event_name != 'release'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: brush-docs
+ path: |
+ brush-docs.tar.gz
+ brush-docs.tar.gz.sha256
+ brush-docs.tar.gz.sha512
+
+ upload-assets:
+ name: ${{ matrix.target }}
+
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
+ runs-on: ${{ matrix.os }}
+ strategy:
+ # Run all jobs to completion regardless of errors.
+ # This is useful because sometimes we fail to compile for a certain target.
+ fail-fast: false
+ matrix:
+ include:
+ - target: aarch64-pc-windows-msvc
+ os: windows-2025
+ force_dry_run: true
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-22.04
+ force_dry_run: false
+ - target: x86_64-apple-darwin
+ os: macos-15-intel
+ force_dry_run: false
+ - target: x86_64-pc-windows-msvc
+ os: windows-2025
+ force_dry_run: true
+ - target: x86_64-unknown-linux-musl
+ os: ubuntu-22.04
+ force_dry_run: false
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-22.04
+ force_dry_run: false
+ - target: aarch64-unknown-linux-musl
+ os: ubuntu-22.04
+ force_dry_run: false
+ - target: aarch64-apple-darwin
+ os: macos-14
+ force_dry_run: false
+ timeout-minutes: 60
+
+ steps:
+ - name: "Checkout repository"
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: "Install Rust toolchain"
+ uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
+
+ - name: "Setup cross-compiling toolchain"
+ if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl')
+ uses: taiki-e/setup-cross-toolchain-action@3d9770ce98eb7dbcf378563182a5e8031165f75b # v1.41.0
+ with:
+ target: ${{ matrix.target }}
+
+ - name: "Install musl cross tools"
+ if: contains(matrix.target, '-musl')
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cross
+
+ - name: "Workaround: clean cache"
+ run: cargo clean
+
+ - name: "Install cargo-about"
+ run: cargo install cargo-about --locked --features cli
+
+ - name: "Generate license notices"
+ working-directory: brush-shell
+ run: cargo about generate -o ../THIRD_PARTY_LICENSES.html about.hbs
+
+ - name: "Update build flags"
+ if: endsWith(matrix.target, 'windows-msvc')
+ run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}"
+
+ - name: "Build and upload binaries to release"
+ uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2
+ id: upload-release
+ with:
+ dry-run: ${{ github.event_name != 'release' || matrix.force_dry_run }}
+ bin: brush
+ locked: true
+ target: ${{ matrix.target }}
+ tar: unix
+ zip: windows
+ checksum: sha256,sha512
+ token: ${{ secrets.GITHUB_TOKEN }}
+ include: "LICENSE,THIRD_PARTY_LICENSES.html"
+
+ - name: "Generate artifact attestation"
+ if: github.event_name == 'release' && !matrix.force_dry_run
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: "${{ steps.upload-release.outputs.archive }}.*"
+
+ - name: "Upload artifacts (dry-run)"
+ if: github.event_name != 'release' || matrix.force_dry_run
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: brush-${{ matrix.target }}
+ path: "${{ steps.upload-release.outputs.archive }}.*"
diff --git a/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml b/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml
new file mode 100644
index 0000000000..0c5d10ed0f
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml
@@ -0,0 +1,138 @@
+name: "PR Reports"
+on:
+ # We intentionally run this workflow in the context of the target of a PR; we are careful
+ # an intentional about how we handle the data coming from the PR.
+ workflow_run: # zizmor: ignore[dangerous-triggers]
+ workflows: ["CI"]
+ types:
+ - completed
+
+permissions: {}
+
+jobs:
+ report:
+ name: "Report"
+
+ permissions:
+ actions: read
+ checks: write
+ contents: read
+ pull-requests: write
+
+ runs-on: ubuntu-24.04
+
+ # We only run this job if the workflow run that completed was triggered by a pull request.
+ if: github.event.workflow_run.event == 'pull_request'
+
+ steps:
+ - name: Extract PR number and create event file
+ id: get-pr
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require('fs');
+
+ // Find the workflow run that triggered this job.
+ const workflowRun = await github.rest.actions.getWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: context.payload.workflow_run.id,
+ });
+
+ // Extract the source commit info.
+ const { head_branch, head_sha, head_repository } = workflowRun.data;
+ const head_repo_owner = head_repository.owner.login;
+ core.setOutput('head_branch', head_branch);
+ core.setOutput('head_sha', head_sha);
+ core.setOutput('head_repo', head_repository.full_name);
+
+ // Try to find all PRs from that source.
+ const { data: prs } = await github.rest.pulls.list({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ head: `${head_repo_owner}:${head_branch}`,
+ per_page: 20
+ });
+
+ // Filter PRs to find the one that targets the main branch.
+ // NOTE: This only supports a target branch of 'main'.
+ const filtered_prs = prs.filter(pr => pr.base.ref === 'main');
+
+ if (filtered_prs.length > 0) {
+ const pr = filtered_prs[0];
+ core.setOutput('pr_number', pr.number);
+
+ // Write out a mostly-stubbed event file.
+ const eventData = {
+ pull_request: {
+ head: {
+ sha: head_sha,
+ repo: {
+ full_name: head_repository.full_name
+ }
+ }
+ }
+ };
+
+ fs.writeFileSync('event-file.json', JSON.stringify(eventData), 'utf-8');
+ } else {
+ core.setOutput('pr_number', '');
+ }
+
+ - name: Download code coverage reports
+ if: steps.get-pr.outputs.pr_number != ''
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: codecov-reports*
+ merge-multiple: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ github.event.workflow_run.id }}
+ path: reports/
+
+ - name: Download performance reports
+ if: steps.get-pr.outputs.pr_number != ''
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: perf-reports*
+ merge-multiple: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ github.event.workflow_run.id }}
+ path: reports/
+
+ - name: Download test results
+ if: steps.get-pr.outputs.pr_number != ''
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: test-reports*
+ merge-multiple: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ github.event.workflow_run.id }}
+ path: reports/
+
+ - name: Show published reports
+ continue-on-error: true
+ if: steps.get-pr.outputs.pr_number != ''
+ run: |
+ ls -lR reports/
+
+ - name: "Publish test results"
+ uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
+ if: steps.get-pr.outputs.pr_number != ''
+ continue-on-error: true
+ with:
+ commit: ${{ github.event.workflow_run.head_sha }}
+ event_file: event-file.json
+ event_name: ${{ github.event.workflow_run.event }}
+ files: reports/test-results-*.xml
+
+ - name: "Publish available .md reports to PR"
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
+ if: steps.get-pr.outputs.pr_number != ''
+ with:
+ path: reports/*.md
+ number: ${{ steps.get-pr.outputs.pr_number }}
diff --git a/local/recipes/shells/brush/source/.github/workflows/ci.yaml b/local/recipes/shells/brush/source/.github/workflows/ci.yaml
new file mode 100644
index 0000000000..6a21407f8d
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/ci.yaml
@@ -0,0 +1,764 @@
+name: "CI"
+on:
+ pull_request:
+ paths-ignore:
+ - "release-plz.toml"
+ push:
+ paths-ignore:
+ - "docs/**"
+ - "**.md"
+ - "LICENSE"
+ - "release-plz.toml"
+ branches:
+ - main
+
+env:
+ RUST_BACKTRACE: 1
+ CARGO_TERM_COLOR: always
+ CLICOLOR: 1
+ CLICOLOR_FORCE: 1
+
+permissions:
+ actions: read
+ contents: read
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # Build and upload release binaries for all relevant architectures.
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Build for x86_64/linux target on native host.
+ # N.B. We intentionally pin to Ubuntu 22.04 for now to increase
+ # the range of distros that will be able to run the produced binaries.
+ # Newer release of Ubuntu upgrade glibc to a point not yet supported
+ # by some latest stable versions of distros.
+ - host: "ubuntu-22.04"
+ target: ""
+ cross_command: ""
+ cross_tool_to_install: ""
+ os: "linux"
+ arch: "x86_64"
+ binary_name: "brush"
+ extra_build_args: ""
+ # Build for x86_64/linux using musl as target C runtime.
+ # We don't bother pinning to an ealier version of Ubuntu because
+ # the produced binary will statically link the C runtime anyhow.
+ - host: "ubuntu-24.04"
+ target: "x86_64-unknown-linux-musl"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "linux-musl"
+ arch: "x86_64"
+ binary_name: "brush"
+ extra_build_args: ""
+ # Build for aarch64/macos target on native host.
+ - host: "macos-latest"
+ target: ""
+ cross_command: ""
+ cross_tool_to_install: ""
+ os: "macos"
+ arch: "aarch64"
+ required_tools: ""
+ binary_name: "brush"
+ extra_build_args: ""
+ # Build for aarch64/linux target on x86_64/Linux host.
+ - host: "ubuntu-24.04"
+ target: "aarch64-unknown-linux-gnu"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "linux"
+ arch: "aarch64"
+ required_tools: "gcc-aarch64-linux-gnu"
+ binary_name: "brush"
+ extra_build_args: ""
+ # Build for wasm32 target on x86_64/linux host.
+ - host: "ubuntu-24.04"
+ target: "wasm32-unknown-unknown"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "unknown"
+ arch: "wasm32"
+ required_tools: ""
+ binary_name: "brush.wasm"
+ extra_build_args: "--no-default-features --features minimal"
+ # Build for WASI-0.2 target on x86_64/linux host.
+ - host: "ubuntu-24.04"
+ target: "wasm32-wasip2"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "wasi-0.2"
+ arch: "wasm32"
+ required_tools: ""
+ binary_name: "brush.wasm"
+ extra_build_args: "--no-default-features --features minimal"
+ # Build for x86_64/windows target on x86_64/linux host.
+ - host: "ubuntu-24.04"
+ target: "x86_64-pc-windows-gnu"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "windows"
+ arch: "x86_64"
+ required_tools: ""
+ binary_name: "brush.exe"
+ extra_build_args: ""
+ # Build for x86_64/android target on x86_64/linux host.
+ - host: "ubuntu-24.04"
+ target: "x86_64-linux-android"
+ cross_command: "cargo ndk"
+ cross_tool_to_install: "cargo-ndk"
+ os: "android"
+ arch: "x86_64"
+ required_tools: ""
+ binary_name: "brush"
+ extra_build_args: ""
+ # x86_64/FreeBSD target on x86_64/linux host, via cross. Compile-check
+ # only (check_only): cross's curated sysroot omits some base-system
+ # libraries (libgeom/libkvm/...) that `sysinfo` links against, so the
+ # final link of a real binary fails. Checking still validates that the
+ # shell compiles for the target; producing a binary is a TODO (would
+ # need a full sysroot with those libraries present).
+ - host: "ubuntu-24.04"
+ target: "x86_64-unknown-freebsd"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "freebsd"
+ arch: "x86_64"
+ required_tools: ""
+ binary_name: "brush"
+ extra_build_args: ""
+ check_only: true
+ # x86_64/NetBSD target on x86_64/linux host, via cross. Compile-check
+ # only for the same reason as FreeBSD above.
+ - host: "ubuntu-24.04"
+ target: "x86_64-unknown-netbsd"
+ cross_command: "cross"
+ cross_tool_to_install: "cross"
+ os: "netbsd"
+ arch: "x86_64"
+ required_tools: ""
+ binary_name: "brush"
+ extra_build_args: ""
+ check_only: true
+ # OpenBSD is tier-3 with no prebuilt std, so even compile-checking it
+ # requires building std from source (-Zbuild-std, hence nightly +
+ # rust-src + no rustup target). panic_abort is built explicitly because
+ # our release profile sets panic = "abort" (plain -Zbuild-std only
+ # builds std + panic_unwind). Compile-check only, like the other BSDs;
+ # check links nothing, so no C cross-compiler/sysroot/linker is needed
+ # (none of the workspace's C-compiling deps are in this default graph).
+ - host: "ubuntu-24.04"
+ target: "x86_64-unknown-openbsd"
+ cross_command: "cargo"
+ cross_tool_to_install: ""
+ os: "openbsd"
+ arch: "x86_64"
+ required_tools: ""
+ binary_name: "brush"
+ extra_build_args: "-Zbuild-std=std,panic_abort"
+ toolchain: "nightly"
+ components: "rust-src"
+ # std is built from source, so there's no prebuilt target to install.
+ skip_rustup_target: true
+ check_only: true
+
+ name: "${{ matrix.check_only && 'Check' || 'Build' }} (${{ matrix.arch }}/${{ matrix.os }})"
+ runs-on: ${{ matrix.host }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: ${{ matrix.toolchain || 'stable' }}
+ targets: ${{ !matrix.skip_rustup_target && matrix.target || '' }}
+ components: ${{ matrix.components || '' }}
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+ with:
+ key: "${{ matrix.target }}"
+
+ - name: Install additional prerequisite tools
+ if: ${{ matrix.required_tools != '' }}
+ env:
+ REQUIRED_TOOLS: ${{ matrix.required_tools }}
+ run: sudo apt-get update -y && sudo apt-get install -y ${REQUIRED_TOOLS}
+
+ - name: Install cross-compilation toolchain
+ if: ${{ matrix.cross_tool_to_install != '' }}
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: ${{ matrix.cross_tool_to_install }}
+
+ - name: "Build (native)"
+ if: ${{ matrix.target == '' }}
+ env:
+ EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
+ run: cargo build --release --all-targets ${EXTRA_BUILD_ARGS}
+
+ - name: "${{ matrix.check_only && 'Check' || 'Build' }} (cross)"
+ if: ${{ matrix.target != '' }}
+ env:
+ CROSS_COMMAND: ${{ matrix.cross_command }}
+ CARGO_SUBCOMMAND: ${{ matrix.check_only && 'check' || 'build' }}
+ TARGET: ${{ matrix.target }}
+ EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
+ CARGO_NDK_PLATFORM: 24
+ run: ${CROSS_COMMAND} ${CARGO_SUBCOMMAND} --release --target=${TARGET} ${EXTRA_BUILD_ARGS}
+
+ - name: "Upload binaries"
+ # check_only targets don't produce a binary to upload.
+ if: ${{ !matrix.check_only }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: binaries-${{ matrix.arch }}-${{ matrix.os }}
+ path: target/${{ matrix.target }}/release/${{ matrix.binary_name }}
+
+ - name: "Upload integration test binaries"
+ if: ${{ matrix.target == '' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: integration-tests-${{ matrix.arch }}-${{ matrix.os }}
+ path: |
+ target/${{ matrix.target }}/release/deps/brush_*_tests-*
+ !**/*.d
+
+ # Test functional correctness
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - host: "ubuntu-24.04"
+ variant: "linux-x86_64"
+ artifact_suffix: "linux-x86_64"
+ name_suffix: "(linux/x86_64)"
+ homebrew_supported: true
+ coverage: true
+ coverage_min_percent: 70
+
+ - host: "ubuntu-24.04-arm"
+ variant: "linux-aarch64"
+ artifact_suffix: "linux-aarch64"
+ name_suffix: "(linux/aarch64)"
+ homebrew_supported: false
+ coverage: true
+ coverage_min_percent: 70
+
+ - host: "macos-latest"
+ variant: "macos"
+ artifact_suffix: "-macos"
+ name_suffix: "(macOS)"
+ homebrew_supported: true
+ coverage: true
+ coverage_min_percent: 70
+
+ - host: "windows-2025"
+ variant: "windows"
+ artifact_suffix: "-windows"
+ name_suffix: "(windows)"
+ homebrew_supported: false
+ coverage: true
+ coverage_min_percent: 20
+
+ - host: "ubuntu-24.04"
+ variant: "wasi"
+ artifact_suffix: "-wasi"
+ name_suffix: "(wasm32/wasi-0.2)"
+ homebrew_supported: false
+ coverage: false
+ extra_rust_targets: "wasm32-wasip2"
+ wasi_runtime: "wasmtime"
+ xtask_test_args: "--wasi"
+
+ name: "Test ${{ matrix.name_suffix }}"
+ runs-on: ${{ matrix.host }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: stable
+ targets: ${{ matrix.extra_rust_targets || '' }}
+ components: llvm-tools-preview
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+ with:
+ # Needed to make sure cargo-deny is correctly cached.
+ cache-all-crates: true
+
+ - name: Install cargo-nextest
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cargo-nextest
+
+ - name: Install cargo-llvm-cov
+ if: ${{ matrix.coverage }}
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cargo-llvm-cov
+
+ - name: Install WASI runtime
+ if: ${{ matrix.wasi_runtime }}
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: ${{ matrix.wasi_runtime }}
+
+ - name: Set up Homebrew
+ if: ${{ matrix.homebrew_supported }}
+ id: set-up-homebrew
+ # We ignore the stale-action-refs check because this action does not have any releases or tags.
+ # We're forced to pick a specific commit.
+ uses: Homebrew/actions/setup-homebrew@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # zizmor: ignore[stale-action-refs]
+ with:
+ stable: true
+
+ - name: "Install recent bash for tests"
+ if: ${{ matrix.homebrew_supported }}
+ run: |
+ set -x
+
+ # Install it
+ brew install bash
+
+ # Find the path
+ BASH_PATH="$(brew --prefix bash)/bin/bash"
+
+ # Log what we're using
+ echo "Using bash from: ${BASH_PATH}"
+ echo "bash version:"
+ ${BASH_PATH} --version
+ echo "BASH_PATH=${BASH_PATH}">>$GITHUB_ENV
+
+ - name: "Download recent bash-completion sources for tests"
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ repository: "scop/bash-completion"
+ ref: "2.17.0"
+ path: "bash-completion"
+
+ - name: "Setup bash-completion"
+ run: echo "BASH_COMPLETION_PATH=${GITHUB_WORKSPACE}/bash-completion/bash_completion">>$GITHUB_ENV
+
+ - name: Test
+ env:
+ VARIANT: ${{ matrix.variant }}
+ run: |
+ args="${{ matrix.xtask_test_args || '' }}"
+ args="${args} --results-output ./test-results-${VARIANT}.xml"
+ if [[ "${{ matrix.coverage }}" == "true" ]]; then
+ args="${args} --coverage --coverage-output ./codecov-${VARIANT}.xml"
+ fi
+ cargo xtask test integration ${args}
+
+ - name: "Upload test results"
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: always()
+ with:
+ name: test-reports${{ matrix.artifact_suffix }}
+ path: test-results-*.xml
+
+ - name: "Generate code coverage report"
+ uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7.0.0
+ if: ${{ always() && matrix.coverage }}
+ id: "code_coverage_report"
+ with:
+ artifact_download_workflow_names: "CI"
+ artifact_name: coverage-%name%${{ matrix.artifact_suffix }}
+ filename: codecov-${{ matrix.variant }}.xml
+ overall_coverage_fail_threshold: ${{ matrix.coverage_min_percent }}
+ only_list_changed_files: ${{ github.event_name == 'pull_request' }}
+ fail_on_negative_difference: true
+ negative_difference_by: "overall"
+ negative_difference_threshold: 5
+
+ - name: "Upload code coverage report"
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: always()
+ with:
+ name: codecov-reports${{ matrix.artifact_suffix }}
+ path: code-coverage-results.md
+
+ # Static analysis of the code.
+ check:
+ name: "Source code checks"
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ # Test latest stable as well as MSRV.
+ rust-version: ["stable", "1.88.0"]
+ # Run checks on Linux, Windows, and macOS.
+ os: ["ubuntu-24.04", "windows-2025", "macos-latest"]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up rust toolchain (${{ matrix.rust-version }})
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: ${{ matrix.rust-version }}
+ components: clippy, rustfmt
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+ with:
+ # Needed to make sure cargo-deny is correctly cached.
+ cache-all-crates: true
+
+ - name: Format check
+ run: cargo xtask check fmt
+
+ - name: Check
+ run: cargo xtask check build
+
+ - name: Install cargo-deny
+ if: runner.os == 'Linux'
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cargo-deny
+
+ # NOTE: Only run on Linux. cargo-deny checks (advisories, licenses, bans,
+ # sources) are platform-independent, so running on one OS is sufficient.
+ # Also works around a cargo-deny 0.19.x bug where its advisory DB parser
+ # panics on Windows due to CRLF line endings (hardcoded LF-only matching).
+ - name: Deny check
+ if: runner.os == 'Linux'
+ run: cargo xtask check deps
+
+ - name: Clippy check
+ if: matrix.rust-version == 'stable'
+ run: cargo xtask check lint
+
+ # Check for unneeded dependencies.
+ check-deps:
+ name: "Check for unneeded dependencies"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up nightly rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: nightly
+
+ - name: Install cargo-udeps
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cargo-udeps
+
+ - name: Check for unused dependencies
+ run: cargo xtask check unused-deps
+
+ # Check that generated schemas are up-to-date
+ check-schemas:
+ name: "Check generated schemas"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: stable
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+
+ - name: Check generated schemas
+ run: cargo xtask check schemas
+
+ # Analyze public API surface
+ analyze-public-api:
+ if: github.event_name == 'pull_request'
+ name: "Analyze public API"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up nightly rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: nightly
+
+ - name: Install cargo-public-api
+ uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
+ with:
+ tool: cargo-public-api
+
+ - name: Set up branches
+ run: |
+ set -euo pipefail
+ git fetch origin main
+
+ - name: Compare public APIs with main
+ run: cargo xtask analyze public-api --base origin/main --output-dir reports
+
+ - name: Upload test report
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-reports-public-api
+ path: reports/*.md
+
+ # Performance analysis of the code.
+ benchmark:
+ if: github.event_name == 'pull_request'
+ name: "Benchmarks"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout PR sources
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ path: pr
+
+ - name: Checkout main sources
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ path: main
+ ref: main
+
+ - name: Set up rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: stable
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+ with:
+ workspaces: |
+ ./pr
+ ./main
+
+ - name: Performance analysis on PR
+ run: cargo xtask analyze bench --output benchmarks.txt
+ working-directory: pr
+
+ - name: Performance analysis on main
+ run: cargo bench --workspace --benches -- --output-format bencher | tee benchmarks.txt
+ working-directory: main
+
+ - name: Compare benchmark results
+ run: |
+ ./pr/scripts/compare-benchmark-results.py -b main/benchmarks.txt -t pr/benchmarks.txt >benchmark-results.md
+
+ - name: Upload performance results
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: perf-reports
+ path: |
+ pr/benchmarks.txt
+ main/benchmarks.txt
+ benchmark-results.md
+
+ # Run bash-completion test suite
+ bash-completion-tests:
+ name: "External tests / bash-completion test suite"
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Checkout brush
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ path: "brush"
+
+ - name: Checkout bash-completion
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ repository: "scop/bash-completion"
+ ref: "2.16.0"
+ path: "bash-completion"
+
+ - name: Download prebuilt brush binaries
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: "binaries-x86_64-linux"
+ path: "binaries"
+
+ - name: Setup downloads
+ run: |
+ chmod +x binaries/*
+ ls -l binaries
+
+ - name: Install prerequisites for running tests
+ run: |
+ set -x
+ sudo apt-get update -y
+ sudo apt-get install -y python3
+ python3 -m pip install --user pytest pytest-xdist pytest-md-report pytest-json-report
+
+ - name: "Run test suite (brush)"
+ working-directory: brush
+ run: |
+ cargo xtask test \
+ --brush-path $GITHUB_WORKSPACE/binaries/brush \
+ external bash-completion \
+ --bash-completion-path $GITHUB_WORKSPACE/bash-completion \
+ --output $GITHUB_WORKSPACE/test-results-bash-completion.json \
+ --summary-output $GITHUB_WORKSPACE/test-results-bash-completion.md || true
+
+ - name: Upload test report
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-reports-bash-completion
+ path: |
+ test-results-bash-completion.md
+
+ # Test release binary on a variety of OS platforms.
+ os-tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # N.B. We don't include Ubuntu because it's already covered by the initial test job.
+ - container: "archlinux:latest"
+ description: "Arch Linux/latest"
+ prereqs_command: "pacman -Sy --noconfirm bash-completion iputils grep less sed util-linux which"
+
+ - container: "mcr.microsoft.com/azurelinux-beta/base/core:4.0"
+ description: "Azure Linux/4.0"
+ prereqs_command: "dnf install -y bash-completion ca-certificates git iputils grep less sed util-linux which"
+
+ - container: "debian:testing"
+ description: "Debian/testing"
+ prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed which"
+
+ - container: "fedora:latest"
+ description: "Fedora/latest"
+ prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux which"
+
+ - container: "nixos/nix:latest"
+ description: "NixOS/latest"
+ prereqs_command: "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install nixpkgs#gnused nixpkgs#hexdump nixpkgs#bash-completion"
+
+ # NOTE: We use Tumbleweed to make sure we get a recent enough version of bash.
+ - container: "opensuse/tumbleweed:latest"
+ description: "openSUSE Tumbleweed/latest"
+ prereqs_command: "zypper install -y bash-completion git which"
+
+ name: "OS target tests (${{ matrix.description }})"
+ runs-on: ubuntu-24.04
+ container: ${{ matrix.container }}
+ needs: build
+ env:
+ # Reference: https://github.com/Azure/azure-cli/issues/29835
+ GNUPGHOME: /root/.gnupg
+ steps:
+ # Install prerequisites *first*; may need some of them for checkout itself.
+ - name: Install prerequisites
+ if: ${{ matrix.prereqs_command != '' }}
+ env:
+ PREREQS_COMMAND: ${{ matrix.prereqs_command }}
+ run: bash -c "${PREREQS_COMMAND}"
+
+ # Workaround path issues on NixOS
+ - name: "Apply workarounds: NixOS"
+ if: ${{ matrix.container == 'nixos/nix:latest' }}
+ run: |
+ set -x
+
+ # Allow use of experimental features to avoid nix from complaining.
+ export NIX_CONFIG="extra-experimental-features = nix-command flakes"
+
+ # The nixos/nix image ships no /etc/os-release (unlike a full NixOS install
+ # and every other container we test against). Provide one so the test harness
+ # can identify the OS, which it relies on for tests' `incompatible_os` tags.
+ printf 'NAME=NixOS\nID=nixos\nPRETTY_NAME="NixOS (nix container)"\n' > /etc/os-release
+
+ # Hacks to get the mounted nodejs by github actions work as it's dynamically linked
+ # https://github.com/actions/checkout/issues/334#issuecomment-716068696
+ nix build --no-link --max-jobs 2 --cores 0 'nixpkgs#stdenv.cc.cc.lib' 'nixpkgs#glibc'
+ echo "LD_LIBRARY_PATH=$(nix path-info 'nixpkgs#stdenv.cc.cc.lib')/lib" >> "$GITHUB_ENV"
+ ln -s "$(nix path-info 'nixpkgs#glibc' --recursive | grep glibc | grep -v bin)/lib64" /lib64
+
+ # Provide alternate path for bash-completion
+ echo "BASH_COMPLETION_PATH=$(nix path-info 'nixpkgs#bash-completion')/share/bash-completion/bash_completion">>"$GITHUB_ENV"
+
+ # Forcibly add system paths
+ echo "BRUSH_TEST_PATH_VAR=$PATH">>"$GITHUB_ENV"
+
+ # Checkout sources for YAML-based test cases
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ path: sources
+
+ - name: Download prebuilt brush binaries
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: binaries-x86_64-linux
+ path: binaries
+
+ - name: Download integration test binaries
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: integration-tests-x86_64-linux
+ path: binaries
+
+ - name: Setup downloads
+ run: |
+ # N.B. Can't use -o pipefail because it's not supported on Debian.
+ set -eux
+ chmod +x binaries/*
+ ls -l -R sources/brush-shell/tests
+ ls -l binaries
+
+ - name: Run tests
+ shell: bash
+ run: |
+ export BRUSH_PATH=$PWD/binaries/brush
+ export BRUSH_VERBOSE=true
+
+ result=0
+ for test_name in binaries/*tests*; do
+ if [[ ${test_name} == *compat* ]]; then
+ export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/compat
+ elif [[ ${test_name} == *integration* ]]; then
+ export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/brush
+ elif [[ ${test_name} == *interactive* || ${test_name} == *version* ]]; then
+ # TODO(tests): Re-enable these tests.
+ echo "WARNING: skipping test: ${test_name}"
+ continue
+ fi
+
+ echo "Running test: ${test_name}"
+ chmod +x ${test_name}
+ ${test_name} || result=$?
+ done
+
+ exit ${result}
diff --git a/local/recipes/shells/brush/source/.github/workflows/codeql.yml b/local/recipes/shells/brush/source/.github/workflows/codeql.yml
new file mode 100644
index 0000000000..731977286d
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/codeql.yml
@@ -0,0 +1,69 @@
+name: "CodeQL Advanced"
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ schedule:
+ - cron: "35 1 * * 3"
+
+permissions: {}
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ permissions:
+ # required for all workflows
+ security-events: write
+ # required to fetch internal or private CodeQL packs
+ packages: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: actions
+ build-mode: none
+ - language: python
+ build-mode: none
+ - language: rust
+ build-mode: none
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ queries: security-extended,security-and-quality
+
+ # - name: Set up rust toolchain
+ # if: ${{ matrix.language == 'rust' }}
+ # uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1
+ # with:
+ # toolchain: stable
+
+ # - name: Enable cargo cache
+ # if: ${{ matrix.language == 'rust' }}
+ # uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+
+ # - name: Build Rust code
+ # if: ${{ matrix.language == 'rust' }}
+ # run: cargo build --all-targets --all-features
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml b/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml
new file mode 100644
index 0000000000..83181fd972
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml
@@ -0,0 +1,72 @@
+name: "Devcontainer"
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ paths:
+ - ".devcontainer/**"
+
+permissions: {}
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: "Build devcontainer"
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: read
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pre-build dev container image
+ uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
+ with:
+ imageName: ghcr.io/reubeno/brush/devcontainer
+ imageTag: latest
+ cacheFrom: ghcr.io/reubeno/brush/devcontainer
+ push: never
+
+ build_and_publish:
+ name: "Build and publish devcontainer"
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pre-build dev container image
+ uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
+ with:
+ imageName: ghcr.io/reubeno/brush/devcontainer
+ imageTag: latest
+ cacheFrom: ghcr.io/reubeno/brush/devcontainer
+ push: filter
+ refFilterForPush: refs/heads/main
+ eventFilterForPush: push
diff --git a/local/recipes/shells/brush/source/.github/workflows/docs.yaml b/local/recipes/shells/brush/source/.github/workflows/docs.yaml
new file mode 100644
index 0000000000..a9b0e24e25
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/docs.yaml
@@ -0,0 +1,77 @@
+name: "Docs"
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+
+env:
+ RUST_BACKTRACE: 1
+ CARGO_TERM_COLOR: always
+ CLICOLOR: 1
+ CLICOLOR_FORCE: 1
+
+permissions:
+ contents: read
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # Check links in docs
+ check-links:
+ name: "Check links"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Check repo-local links
+ uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
+ with:
+ args: ". --offline --verbose --no-progress"
+ fail: true
+
+ # Generate docs content
+ generate-docs:
+ name: "Generate usage docs"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up rust toolchain
+ uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
+ with:
+ toolchain: stable
+
+ - name: Enable cargo cache
+ uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+
+ - name: Create dirs
+ run: mkdir -p ./md ./man
+
+ - name: Build markdown info
+ run: cargo xtask gen docs markdown --out ./md/brush.md
+
+ - name: Upload markdown docs
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: docs-markdown
+ path: md
+
+ - name: Build man pages
+ run: cargo xtask gen docs man --output-dir ./man
+
+ - name: Upload man pages
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: docs-man
+ path: man
diff --git a/local/recipes/shells/brush/source/.github/workflows/spelling.yaml b/local/recipes/shells/brush/source/.github/workflows/spelling.yaml
new file mode 100644
index 0000000000..769a970cdb
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/spelling.yaml
@@ -0,0 +1,29 @@
+name: Spelling
+on: [pull_request]
+
+env:
+ RUST_BACKTRACE: 1
+ CARGO_TERM_COLOR: always
+ CLICOLOR: 1
+ CLICOLOR_FORCE: 1
+
+permissions:
+ contents: read
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ spelling:
+ name: spell-check
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout brush
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Spell check repo
+ uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
diff --git a/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml b/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml
new file mode 100644
index 0000000000..50df109374
--- /dev/null
+++ b/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml
@@ -0,0 +1,46 @@
+#
+# This workflow was based on https://docs.zizmor.sh/usage/#use-in-github-actions
+#
+
+name: GitHub Actions Security Analysis with zizmor 🌈
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["**"]
+
+permissions: {}
+
+# Only allow one run of the workflow per branch / PR at a time.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ zizmor:
+ name: zizmor latest via PyPI
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write # needed for SARIF uploads
+ contents: read # only needed for private repos
+ actions: read # only needed for private repos
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+
+ - name: Run zizmor 🌈
+ run: uvx zizmor --format=sarif . > results.sarif
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload SARIF file
+ uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
+ with:
+ sarif_file: results.sarif
+ category: zizmor
diff --git a/local/recipes/shells/brush/source/.gitignore b/local/recipes/shells/brush/source/.gitignore
new file mode 100644
index 0000000000..3823322d45
--- /dev/null
+++ b/local/recipes/shells/brush/source/.gitignore
@@ -0,0 +1,2 @@
+target/
+mutants.out*/
diff --git a/local/recipes/shells/brush/source/.lycheeignore b/local/recipes/shells/brush/source/.lycheeignore
new file mode 100644
index 0000000000..61cad0becc
--- /dev/null
+++ b/local/recipes/shells/brush/source/.lycheeignore
@@ -0,0 +1,2 @@
+https://github.com/reubeno/brush/*
+target/*
diff --git a/local/recipes/shells/brush/source/AGENTS.md b/local/recipes/shells/brush/source/AGENTS.md
new file mode 100644
index 0000000000..9df94322f1
--- /dev/null
+++ b/local/recipes/shells/brush/source/AGENTS.md
@@ -0,0 +1,332 @@
+# Agent Development Guide for `brush`
+
+This guide helps AI agents work efficiently on the `brush` codebase by providing essential context about architecture, patterns, and development workflows.
+
+## 1. Architecture Overview & Navigation
+
+### Project Structure
+
+The brush project is organized into several key crates:
+
+- **`brush-core/`**: Core shell functionality, builtins, and runtime
+- **`brush-parser/`**: Shell script parsing (AST generation)
+- **`brush-builtins/`**: Implementation of shell builtins (e.g., echo, cd)
+- **`brush-interactive/`**: Interactive shell interfaces (readline, etc.)
+- **`brush-shell/`**: Main CLI application and entry point
+
+### Key Files & Entry Points
+
+**Critical files to understand first:**
+
+- `brush-core/src/shell.rs` - Main `Shell` struct and creation logic
+- `brush-core/src/lib.rs` - Public API exports
+- `brush-shell/src/main.rs` - CLI application entry point
+
+**Architecture patterns:**
+
+- Shell instances are created via `Shell::builder()`
+- The project uses builder patterns for type-safe configuration
+- We try to keep platform-specific code in `brush-core` under the `sys` module
+- Follows Rust 2024 edition standards
+
+### Module Dependencies
+
+```text
+brush-shell → brush-interactive → brush-core → brush-parser
+ ↘ brush-builtins ↗
+```
+
+## 2. Testing Strategy
+
+### Test Execution Priority
+
+**Recommended development workflow:**
+
+#### Using xtask (Recommended)
+
+The project provides a `cargo xtask` command that centralizes common development tasks:
+
+```bash
+# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
+cargo xtask ci quick
+
+# Run full pre-commit checks (~45s warm): quick + deps, schemas, integration tests
+cargo xtask ci pre-commit
+
+# Run with --continue-on-error to see all failures at once
+cargo xtask ci pre-commit -k
+
+# Add -v for verbose output showing exact commands being run
+cargo xtask -v ci pre-commit
+```
+
+#### Individual Test Commands
+
+```bash
+# Run unit tests (fast tests excluding integration binaries)
+cargo xtask test unit
+
+# Run integration tests (all workspace tests including compat tests)
+cargo xtask test integration
+
+# Run tests with coverage
+cargo xtask test integration --coverage --coverage-output codecov.xml
+```
+
+#### Manual Approach (Alternate)
+
+For finer-grained control:
+
+##### Inner Loop (Fast Iteration)
+
+1. **Quick validation**: `cargo check --package ` - Fast syntax/type checking
+2. **Correctness validation**: `cargo test --package ` - Target specific crates for faster feedback
+
+##### Outer Loop (Comprehensive Testing)
+
+1. **Compatibility tests**: `cargo test --test brush-compat-tests` - Bash compatibility validation
+2. **Full workspace tests**: `cargo test --workspace` - Complete test suite
+
+#### Pre-Finish Quality Validation
+
+**Recommended:** Run the xtask pre-commit workflow:
+
+```bash
+cargo xtask ci pre-commit
+```
+
+**Manual approach:** Before considering work complete, run these validation steps:
+
+- **Compatibility tests**: `cargo test --test brush-compat-tests`
+- **Linting**: `cargo clippy`
+- **Formatting**: `cargo fmt --check`
+- **Security/License audit**: `cargo deny check all`
+- **Full test suite**: `cargo test --workspace`
+
+**When tests fail:**
+
+- Focus on failures in the area you changed first
+- Compatibility test failures often indicate shell behavior changes
+- Check if new functionality needs corresponding test cases
+- Format/clippy failures should be fixed before proceeding
+
+**Common pitfalls:**
+
+- **Test scope mistakes**: Running full test suite too early instead of targeting specific areas first
+- **Skipping test-driven development**: Add tests that specify desired behavior before implementing
+
+**Test-driven development approach:**
+
+- When possible, write tests first that specify the desired behavior
+- Use unit tests for logic changes, compatibility tests for shell behavior changes
+- Use these tests as validation that your implementation is working correctly
+
+**Pro tip**: For specific compatibility test cases, use:
+
+```bash
+cargo test --test brush-compat-tests -- ''
+```
+
+**Fast iteration strategies:**
+
+- Target specific crates: `cargo test --package `
+- Target specific test cases: `cargo test ` or `cargo test --test `
+- Requires knowledge of which tests best exercise the code being changed
+
+**Testing approach:**
+
+- Follow good software engineering practice: start by validating the specific area being changed, then iteratively move to incrementally broader sets of tests
+
+### Test Organization
+
+**Testing expectations for new public APIs:**
+
+- Unit tests are expected if feasible
+- Examples are nice to have and worthwhile for sufficiently critical APIs
+
+**Test patterns and conventions:**
+
+- **Compatibility tests**: For any compatibility-related fixes, it's critical to add new test cases to the compat tests (see docs/how-to/run-tests.md and section 3 for when breaking changes apply)
+
+**Test categories:**
+
+- Unit tests: In `src/` files with `#[cfg(test)]`
+- Integration tests: In `tests/` directories
+- Examples: In `examples/` directories (must be runnable)
+- Shell script tests: YAML-based test cases in `brush-shell/tests/cases/`
+
+### Performance Testing
+
+**Performance regression testing:**
+
+- Not a chief concern for most changes
+- For performance-specific work, benchmarks are available (see docs/how-to/run-benchmarks.md)
+- Performance sensitivity will be identified in the initial brief if relevant
+
+## 3. Breaking Changes & Compatibility
+
+### API Stability Guidelines
+
+**Breaking change policy:**
+
+- Non-backwards compatible changes to public APIs are considered breaking
+- Breaking changes are still in consideration, but need to be highlighted and carefully reviewed
+- Any APIs exported from crates are considered public because all of the crates are published to crates.io
+
+**Adding new fields to public structs:**
+
+- New optional fields are fine to add as long as the struct implements the Default trait and as long as the defaulted value is a sensible one
+
+### Dependency Impact
+
+When changing public APIs in `brush-core` (see section 3 for breaking change policy):
+
+1. Check `brush-shell/src/main.rs` for struct initialization sites
+2. Check `brush-interactive/` for any usage
+
+## 4. Documentation & Examples Standards
+
+### Documentation Requirements
+
+**Rustdoc documentation standards:**
+
+- At minimum we must have good rustdoc documentation for exported types, functions, traits, etc. as well as on all exported modules and crates
+- Documentation for internal components should be a best-effort, nice to have thing
+
+**Examples for new features:**
+
+- Unless explicitly requested, only major feature additions warrant an example.
+
+**Documentation style:**
+
+- Follow general best practices for Rust
+
+### Example Standards
+
+Examples should:
+
+- Be self-contained and runnable with `cargo run --package brush-core --example `
+- Include comprehensive error handling
+- Demonstrate both basic and advanced usage patterns
+- Include output examples in comments when helpful
+
+## 5. Build & Release Process
+
+### Development Tools
+
+The project uses several tools for code quality:
+
+**Using xtask (Recommended):**
+
+The project provides a `cargo xtask` command that centralizes common development tasks:
+
+```bash
+# Run all pre-commit checks (comprehensive)
+cargo xtask ci pre-commit
+
+# Individual checks
+cargo xtask check fmt # Format check
+cargo xtask check lint # Clippy
+cargo xtask check deps # cargo-deny
+cargo xtask check build # Compilation check
+cargo xtask check schemas # Schema drift check
+
+# Tests
+cargo xtask test unit # Fast unit tests (excludes integration binaries)
+cargo xtask test integration # All workspace tests (unit + compat)
+
+# Analysis
+cargo xtask analyze bench # Run benchmarks
+```
+
+**Manual approach (Alternate):**
+
+- Standard cargo commands (e.g., check, test, build, run, clippy)
+- You may need to reverse engineer some of the args looking at CI checks in .github/*.yml
+
+**Command frequency guidelines:**
+
+- **Frequent (inner loop)**: `cargo xtask ci quick`, `cargo check`, `cargo test --package `
+- **Regular (before commits)**: `cargo xtask ci pre-commit` or `cargo fmt` + `cargo clippy`
+- **Occasional (outer loop)**: `cargo xtask test integration` or `cargo test --workspace`
+- **Rare (pre-finish only)**: `cargo xtask check deps` or `cargo deny check`
+
+**Pre-commit validation:**
+
+- Recommended: `cargo xtask ci pre-commit`
+- Quick check: `cargo xtask ci quick` for fast feedback
+- Manual: Run `cargo fmt` and `cargo clippy` before committing
+
+**Outer loop validation:**
+
+- `cargo deny check all` should pass (security/license auditing) - not for frequent use during development
+
+## 6. Performance & Error Handling Patterns
+
+### Error Handling
+
+**Error handling patterns:**
+
+- `thiserror` is used for implementing crate-specific errors
+- Use `anyhow` only in tests
+
+**Logging and tracing patterns:**
+
+- Use `tracing` for debug logging with predefined categories
+- Categories are defined in `trace_categories.rs` modules (e.g., `COMMANDS`, `COMPLETION`, `EXPANSION`, `FUNCTIONS`, `INPUT`, `JOBS`, `PARSE`, `PATTERN`, `UNIMPLEMENTED`)
+- Usage pattern: `tracing::debug!(target: trace_categories::CATEGORY_NAME, "message")`
+- Example: `tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", job_id)`
+
+### Performance Considerations
+
+**Clone vs references:**
+
+- Avoid cloning by default, no reason to make extra copies
+- Only use cloning when you really must capture a separate copy for async safety or similarly important reasons
+
+---
+
+## 7. Commit Message Conventions
+
+Following the Linux kernel convention, when AI assistants or advanced coding tools have been used in the development of a change, include an `Assisted-by:` tag in the commit message. This provides transparency about the use of such tools in the development process.
+
+The format for the Assisted-by tag is:
+```
+Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]
+```
+
+Example:
+```
+Assisted-by: Mistral Vibe:mistral-medium-3.5
+```
+
+## Quick Reference Checklist
+
+When making changes to brush:
+
+### Before Starting
+
+- [ ] Understand which crate(s) are affected
+- [ ] Check if changes might break dependent crates
+- [ ] Identify relevant test files and examples
+
+### During Development
+
+- [ ] Run `cargo check` frequently during development
+- [ ] Test changes with package-specific tests first (see section 2 for testing workflow)
+- [ ] Update dependent crate usage if needed (see section 3 for compatibility considerations)
+- [ ] Add/update examples for major feature additions only (see section 4)
+
+### Before Committing
+
+- [ ] Run full test suite: `cargo test` (see section 2 for complete testing workflow)
+- [ ] Format code: `cargo fmt` (see section 5 for tool details)
+- [ ] Check linting: `cargo clippy`
+- [ ] Use conventional commit format
+- [ ] If using AI assistants or advanced coding tools, include an `Assisted-by:` tag in the format `AGENT_NAME:MODEL_VERSION` (following Linux kernel convention)
+
+### Documentation
+
+- [ ] Add rustdoc to exported APIs (see section 4 for documentation standards)
+- [ ] Include working examples for major features only
+- [ ] Update this guide if new patterns emerge
diff --git a/local/recipes/shells/brush/source/CHANGELOG.md b/local/recipes/shells/brush/source/CHANGELOG.md
new file mode 100644
index 0000000000..ec56f3a776
--- /dev/null
+++ b/local/recipes/shells/brush/source/CHANGELOG.md
@@ -0,0 +1,1074 @@
+# Changelog
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+All notable changes to this project will be documented in this file.
+
+## [0.4.0] - 2026-05-02
+
+### 🚀 Features
+
+- *(parser)* Add official `serde` feature to `brush-parser` for AST serialization ([#783](https://github.com/reubeno/brush/pull/783))
+- *(parser)* [**breaking**] Replace ParsingNearToken with ParsingNear ([#1022](https://github.com/reubeno/brush/pull/1022))
+- Introduce notion of marking errors as fatal ([#770](https://github.com/reubeno/brush/pull/770))
+- Expose key bindings for hint acceptance ([#802](https://github.com/reubeno/brush/pull/802))
+- Add 'serde' feature to `brush-core` ([#831](https://github.com/reubeno/brush/pull/831))
+- Enable using word expander without command substs ([#877](https://github.com/reubeno/brush/pull/877))
+- Enable session leading config ([#904](https://github.com/reubeno/brush/pull/904))
+- Implement \l sequence in prompt expansion ([#913](https://github.com/reubeno/brush/pull/913))
+- *(windows)* Various path fixes for windows platform ([#1075](https://github.com/reubeno/brush/pull/1075))
+- Implement fatal error propagation ([#773](https://github.com/reubeno/brush/pull/773))
+- Implement `set -u` semantics ([#774](https://github.com/reubeno/brush/pull/774))
+- Source tracking improvements ([#805](https://github.com/reubeno/brush/pull/805))
+- Support asynchronous builtin execution ([#810](https://github.com/reubeno/brush/pull/810))
+- *(builtins)* Implement caller builtin ([#812](https://github.com/reubeno/brush/pull/812))
+- Implement "compgen -A binding" ([#814](https://github.com/reubeno/brush/pull/814))
+- Emulate `exec` in subshell ([#823](https://github.com/reubeno/brush/pull/823))
+- Experimental builtin for serializing full shell state ([#835](https://github.com/reubeno/brush/pull/835))
+- Expand tilde expansion support ([#842](https://github.com/reubeno/brush/pull/842))
+- Implement `set -e` (a.k.a. `errexit`) + `pipefail` semantics ([#852](https://github.com/reubeno/brush/pull/852))
+- Experimental shell/terminal integration ([#872](https://github.com/reubeno/brush/pull/872))
+- *(hooks)* Experimental `zsh`-style preexec/precmd hooks ([#652](https://github.com/reubeno/brush/pull/652))
+- Rudimentary readline macro support ([#880](https://github.com/reubeno/brush/pull/880))
+- Optional TOML-based config file for brush-shell ([#895](https://github.com/reubeno/brush/pull/895))
+- *(read)* Fill out more of `read` builtin ([#914](https://github.com/reubeno/brush/pull/914))
+- Add convenience cmd line option to set up xtrace ([#915](https://github.com/reubeno/brush/pull/915))
+- Scaffolding for winnow parser ([#974](https://github.com/reubeno/brush/pull/974))
+- *(completion)* Implement COMP_KEY and COMP_TYPE ([#1008](https://github.com/reubeno/brush/pull/1008))
+- *(options)* Implement failglob option ([#1011](https://github.com/reubeno/brush/pull/1011))
+- *(well-known-vars)* Implement BASH_ARGC and BASH_ARGV variables ([#1013](https://github.com/reubeno/brush/pull/1013))
+- *(getopts)* Implement OPTERR semantics ([#1048](https://github.com/reubeno/brush/pull/1048))
+- *(mapfile)* Add `-O` flag ([#558](https://github.com/reubeno/brush/pull/558))
+- Add coproc AST and placeholder execution ([#1029](https://github.com/reubeno/brush/pull/1029))
+- *(trap)* Implement ERR trap ([#1020](https://github.com/reubeno/brush/pull/1020))
+- *(windows)* Add compat /dev/null handling ([#1044](https://github.com/reubeno/brush/pull/1044))
+- *(coproc)* Implement coprocs ([#1068](https://github.com/reubeno/brush/pull/1068))
+- Implement $_ well-known variable for last command argument ([#1030](https://github.com/reubeno/brush/pull/1030))
+- Add optional bundling of coreutils builtins behind experimental feature flag ([#1031](https://github.com/reubeno/brush/pull/1031))
+
+### 🐛 Bug Fixes
+
+- Fill in more SourceLocation implementations ([#804](https://github.com/reubeno/brush/pull/804))
+- Correct parsing of escaped backslash in ANSI-C quotes ([#850](https://github.com/reubeno/brush/pull/850))
+- Bang formatted without space leads to invalid command ([#1109](https://github.com/reubeno/brush/pull/1109))
+- Avoid allocating in `pre_exec` closure ([#777](https://github.com/reubeno/brush/pull/777))
+- Improve "compgen -A {user,group}" ([#815](https://github.com/reubeno/brush/pull/815))
+- Correct completion word tokenizing ([#816](https://github.com/reubeno/brush/pull/816))
+- Honor mark-directories option ([#817](https://github.com/reubeno/brush/pull/817))
+- Correct scope mismatch on error ([#821](https://github.com/reubeno/brush/pull/821))
+- Suppress color when help is redirected ([#822](https://github.com/reubeno/brush/pull/822))
+- History import robustness ([#878](https://github.com/reubeno/brush/pull/878))
+- Avoid some allocation in brace-expansion ([#884](https://github.com/reubeno/brush/pull/884))
+- Eliminate unneeded clone ([#886](https://github.com/reubeno/brush/pull/886))
+- More direct Intos ([#946](https://github.com/reubeno/brush/pull/946))
+- Remove ref-options ([#952](https://github.com/reubeno/brush/pull/952))
+- Don't clobber builtin usage error if error display fails ([#965](https://github.com/reubeno/brush/pull/965))
+- Use std::env::{split_paths, join_paths} for `PATH` splitting/combining ([#968](https://github.com/reubeno/brush/pull/968))
+- Handle more readline macro forms ([#967](https://github.com/reubeno/brush/pull/967))
+- Build for freebsd ([#980](https://github.com/reubeno/brush/pull/980))
+- *(commands)* Use async pipe reads for command substitution
+- Do not panic on file clone failure ([#1051](https://github.com/reubeno/brush/pull/1051))
+- *(wasm)* Avoid panic in split_paths on wasm ([#1064](https://github.com/reubeno/brush/pull/1064))
+- *(android)* Address android and 32-bit build issues ([#1070](https://github.com/reubeno/brush/pull/1070))
+- Address hang on macos when used as login shell ([#1095](https://github.com/reubeno/brush/pull/1095))
+- *(windows)* Fix /dev/null handling ([#1104](https://github.com/reubeno/brush/pull/1104))
+- Expose -c flag via $- ([#767](https://github.com/reubeno/brush/pull/767))
+- Don't allow builtin to invoke disabled builtin ([#719](https://github.com/reubeno/brush/pull/719))
+- Getopts with no provided args ([#796](https://github.com/reubeno/brush/pull/796))
+- Preserve $? during interactive shell prompt expansion ([#798](https://github.com/reubeno/brush/pull/798))
+- Correct initialization of SHELL var ([#800](https://github.com/reubeno/brush/pull/800))
+- Correct exit status handling in assignment-only cmds ([#801](https://github.com/reubeno/brush/pull/801))
+- Extended test redirection ([#811](https://github.com/reubeno/brush/pull/811))
+- Negative substring offsets ([#818](https://github.com/reubeno/brush/pull/818))
+- Do not tilde-expand prompts ([#819](https://github.com/reubeno/brush/pull/819))
+- Escaping in pattern character sets ([#824](https://github.com/reubeno/brush/pull/824))
+- Special-casing for OLDPWD ([#825](https://github.com/reubeno/brush/pull/825))
+- Omit unset vars from `set` builtin output ([#826](https://github.com/reubeno/brush/pull/826))
+- Getopts short options ([#827](https://github.com/reubeno/brush/pull/827))
+- Update default of COMP_WORDBREAKS ([#828](https://github.com/reubeno/brush/pull/828))
+- Disable reedline for cmd mode / script exec ([#845](https://github.com/reubeno/brush/pull/845))
+- Correct trace depth computation ([#847](https://github.com/reubeno/brush/pull/847))
+- Assorted ANSI-C quote compat fixes ([#851](https://github.com/reubeno/brush/pull/851))
+- True and false must ignore all args ([#865](https://github.com/reubeno/brush/pull/865))
+- Gracefully handle closed stdout ([#873](https://github.com/reubeno/brush/pull/873))
+- Gracefully handle closed stderr ([#875](https://github.com/reubeno/brush/pull/875))
+- Delay profile/rc loading until after input backend attached ([#879](https://github.com/reubeno/brush/pull/879))
+- *(completion)* Properly escape filenames with spaces or special chars ([#870](https://github.com/reubeno/brush/pull/870))
+- *(unset)* Allow unsetting non-standard names ([#893](https://github.com/reubeno/brush/pull/893))
+- *(test)* Correct handling of -- and invalid test exprs ([#894](https://github.com/reubeno/brush/pull/894))
+- Reenable a few tests ([#896](https://github.com/reubeno/brush/pull/896))
+- Handle redir form: `>&` PATH ([#908](https://github.com/reubeno/brush/pull/908))
+- Compat fixes for \[ and \] in prompt expansion ([#909](https://github.com/reubeno/brush/pull/909))
+- Parsing array accesses in array indices ([#910](https://github.com/reubeno/brush/pull/910))
+- Add missing assignment arithmetic operators (|=, ^=) ([#911](https://github.com/reubeno/brush/pull/911))
+- Correct overzealous regex escaping ([#912](https://github.com/reubeno/brush/pull/912))
+- Systematic removal of problematic .unwrap() calls ([#921](https://github.com/reubeno/brush/pull/921))
+- *(builtins)* Shadowing special builtins with funcs ([#922](https://github.com/reubeno/brush/pull/922))
+- *(hash)* Accept names with slashes without lookup or error ([#924](https://github.com/reubeno/brush/pull/924))
+- Ignore errors in -x tracing ([#928](https://github.com/reubeno/brush/pull/928))
+- Trim space in test comparisons ([#950](https://github.com/reubeno/brush/pull/950))
+- Exit status in command arg expansion failure ([#951](https://github.com/reubeno/brush/pull/951))
+- Correctly handle pipe errors in builtins ([#953](https://github.com/reubeno/brush/pull/953))
+- *(completion)* Provide default fallback completion for shell vars ([#954](https://github.com/reubeno/brush/pull/954))
+- *(extglob)* Handle escaped parens ([#960](https://github.com/reubeno/brush/pull/960))
+- *(patterns)* Correct backslash preservation ([#963](https://github.com/reubeno/brush/pull/963))
+- *(return)* Exit code when stderr is not writable ([#975](https://github.com/reubeno/brush/pull/975))
+- Check func names against reserved words ([#978](https://github.com/reubeno/brush/pull/978))
+- *(test)* Resolve bash to absolute path before running compat tests ([#994](https://github.com/reubeno/brush/pull/994))
+- *(pathsearch)* Include symlink'd executables in search ([#991](https://github.com/reubeno/brush/pull/991)) ([#992](https://github.com/reubeno/brush/pull/992))
+- Readonly should operate on global scope ([#1003](https://github.com/reubeno/brush/pull/1003))
+- *(peg)* Support legacy $[expr] arithmetic syntax ([#1004](https://github.com/reubeno/brush/pull/1004))
+- *(arrays)* Add set + unset support for negative array indices ([#1005](https://github.com/reubeno/brush/pull/1005))
+- *(expansion)* Allow ${#arr[i]} on unset variables with set -u ([#1007](https://github.com/reubeno/brush/pull/1007))
+- *(bind)* Silently succeed when key bindings unavailable ([#1009](https://github.com/reubeno/brush/pull/1009))
+- *(compgen)* Disable pathname expansion for -W word list ([#1010](https://github.com/reubeno/brush/pull/1010))
+- *(completion)* Only deduplicate completions in interactive presentation ([#1012](https://github.com/reubeno/brush/pull/1012))
+- *(trap)* - with multiple signals should clear all handlers ([#1015](https://github.com/reubeno/brush/pull/1015))
+- *(heredoc)* Preserve quotes in heredoc expansion (rebased) ([#1014](https://github.com/reubeno/brush/pull/1014))
+- *(arithmetic)* Allow space around prefix arithmetic operators ([#1016](https://github.com/reubeno/brush/pull/1016))
+- *(arithmetic)* More faithful overflow/underflow handling ([#1017](https://github.com/reubeno/brush/pull/1017))
+- *(arithmetic-for)* Correct parsing + eval issues ([#1018](https://github.com/reubeno/brush/pull/1018))
+- *(arithmetic)* Implement high-radix literals ([#1019](https://github.com/reubeno/brush/pull/1019))
+- *(printf)* Quote empty strings as '' in printf %q ([#1026](https://github.com/reubeno/brush/pull/1026))
+- *(expansion)* Correct descending brace sequence expansion with step ([#1025](https://github.com/reubeno/brush/pull/1025))
+- *(builtins)* Add builtin commands to `compgen -A command`([#997](https://github.com/reubeno/brush/pull/997)) ([#1027](https://github.com/reubeno/brush/pull/1027))
+- *(expansion)* Preserve quoted empty strings with nullglob enabled ([#1035](https://github.com/reubeno/brush/pull/1035))
+- *(command_subst)* Strip NULL bytes from command substitutions ([#1049](https://github.com/reubeno/brush/pull/1049))
+- Return 2 for unknown cmdline options ([#1050](https://github.com/reubeno/brush/pull/1050))
+- *(complete)* Complete current and parent directories. ([#887](https://github.com/reubeno/brush/pull/887))
+- *(arithmetic)* Detect infinite arithmetic var reference recursion ([#1021](https://github.com/reubeno/brush/pull/1021))
+- *(tokenizer)* Prioritize heredoc body over terminating char in `next_token_until` ([#1055](https://github.com/reubeno/brush/pull/1055))
+- *(tokenizer)* Unquote heredoc end tag in `delimit_current_token` ([#1056](https://github.com/reubeno/brush/pull/1056)) ([#1057](https://github.com/reubeno/brush/pull/1057))
+- Use i64 for exit ([#1065](https://github.com/reubeno/brush/pull/1065))
+- *(heredoc)* Handle unbalanced single quotes and backticks in heredoc bodies inside `$(…)` ([#1067](https://github.com/reubeno/brush/pull/1067))
+- *(cmdline)* Handle `--` as option terminator for `-c` flag ([#1076](https://github.com/reubeno/brush/pull/1076))
+- *(login)* Correctly reflect login option via shopt ([#1078](https://github.com/reubeno/brush/pull/1078))
+- *(-c)* Call EXIT traps on exit; add tests ([#1080](https://github.com/reubeno/brush/pull/1080))
+- *(builtins)* Add reserved keywords to `compgen -A command` ([#1047](https://github.com/reubeno/brush/pull/1047))
+- *(-f)* Implement -f command line option ([#1091](https://github.com/reubeno/brush/pull/1091))
+- *(parser)* Preserve whitespace in regex operands of extended tests ([#1096](https://github.com/reubeno/brush/pull/1096))
+- *(wellknownvars)* Set OSTYPE on macOS ([#1097](https://github.com/reubeno/brush/pull/1097))
+
+### 🚜 Refactor
+
+- Reexport parser types ([#784](https://github.com/reubeno/brush/pull/784))
+- Reorganize existing PEG parser ([#899](https://github.com/reubeno/brush/pull/899))
+- Split shell.rs => shell/*.rs ([#945](https://github.com/reubeno/brush/pull/945))
+- Use let chains ([#982](https://github.com/reubeno/brush/pull/982))
+- Make `try_iter_open_fds` fully iterator-based ([#1001](https://github.com/reubeno/brush/pull/1001))
+- Rename TokenLocation -> SourceSpan ([#788](https://github.com/reubeno/brush/pull/788))
+- Merge func call and script stacks ([#789](https://github.com/reubeno/brush/pull/789))
+- Merge brushinfo and brushctl ([#813](https://github.com/reubeno/brush/pull/813))
+- Shell / interactive-shell layering ([#832](https://github.com/reubeno/brush/pull/832))
+- Make Shell fields private ([#900](https://github.com/reubeno/brush/pull/900))
+- Extract build/test/validate commands into `xtask` ([#898](https://github.com/reubeno/brush/pull/898))
+- Reuse test harness for non-oracle tests ([#916](https://github.com/reubeno/brush/pull/916))
+- [**breaking**] Make `Shell` generic over `ShellExtensions` type param ([#941](https://github.com/reubeno/brush/pull/941))
+
+### 📚 Documentation
+
+- Update todo comments ([#787](https://github.com/reubeno/brush/pull/787))
+- Update readme ([#853](https://github.com/reubeno/brush/pull/853))
+- Update readme ([#976](https://github.com/reubeno/brush/pull/976))
+- Minor updates ([#1061](https://github.com/reubeno/brush/pull/1061))
+- Update compat tables ([#1062](https://github.com/reubeno/brush/pull/1062))
+- Update header image on readme ([#1113](https://github.com/reubeno/brush/pull/1113))
+- Update reference docs ([#1114](https://github.com/reubeno/brush/pull/1114))
+
+### ⚡ Performance
+
+- Assorted performance changes ([#936](https://github.com/reubeno/brush/pull/936))
+- Prealloc capacity for some vectors, remove unnecessary Strings ([#940](https://github.com/reubeno/brush/pull/940))
+- Address regex cache contention ([#1043](https://github.com/reubeno/brush/pull/1043))
+
+### 🧪 Testing
+
+- *(parser)* Per syntax element tests ([#938](https://github.com/reubeno/brush/pull/938))
+- Add error handling tests, resolve nextest issue ([#768](https://github.com/reubeno/brush/pull/768))
+- Extend IFS tests ([#775](https://github.com/reubeno/brush/pull/775))
+- Add not-yet-passing call-stack/`caller` tests ([#785](https://github.com/reubeno/brush/pull/785))
+- Skip confused test ([#820](https://github.com/reubeno/brush/pull/820))
+- Refactor + augment expansion test cases ([#846](https://github.com/reubeno/brush/pull/846))
+- More tests for traps and related options ([#947](https://github.com/reubeno/brush/pull/947))
+- Add mkosi config ([#1093](https://github.com/reubeno/brush/pull/1093))
+- Add basic wasm32-wasip2 tests to CI ([#1098](https://github.com/reubeno/brush/pull/1098))
+- *(nameref)* Add large set of nameref-focused tests ([#1102](https://github.com/reubeno/brush/pull/1102))
+- Temporarily disable flaky test ([#1103](https://github.com/reubeno/brush/pull/1103))
+
+### ⚙️ Miscellaneous Tasks
+
+- Rename feature: fuzz-testing => arbitrary ([#844](https://github.com/reubeno/brush/pull/844))
+- Switch non-Unix plats to use std::env::home_dir ([#830](https://github.com/reubeno/brush/pull/830))
+- Update dependencies ([#985](https://github.com/reubeno/brush/pull/985))
+- *(clippy)* Fix nightly lints ([#998](https://github.com/reubeno/brush/pull/998))
+- Enable windows tests ([#1083](https://github.com/reubeno/brush/pull/1083))
+- Replace git expectrl dependency with v0.8.0 release ([#779](https://github.com/reubeno/brush/pull/779))
+- Workflow sync + cleanup ([#790](https://github.com/reubeno/brush/pull/790))
+- Upgrade several dependencies ([#843](https://github.com/reubeno/brush/pull/843))
+- Add placeholders for new named options (not yet supported) ([#848](https://github.com/reubeno/brush/pull/848))
+- Remove pprof dependency ([#891](https://github.com/reubeno/brush/pull/891))
+- Update dependencies ([#892](https://github.com/reubeno/brush/pull/892))
+- Update dependencies ([#923](https://github.com/reubeno/brush/pull/923))
+- Upgrade deps ([#942](https://github.com/reubeno/brush/pull/942))
+- Update dependencies ([#966](https://github.com/reubeno/brush/pull/966))
+- Update dependencies ([#977](https://github.com/reubeno/brush/pull/977))
+- Upgrade MSRV to 1.88 and update deps ([#981](https://github.com/reubeno/brush/pull/981))
+- Upgrade dependencies ([#1063](https://github.com/reubeno/brush/pull/1063))
+- Address clippy error in xtask ([#1108](https://github.com/reubeno/brush/pull/1108))
+
+### Build
+
+- *(deps)* Bump utf8-chars from 3.0.5 to 3.0.6 in the cargo group ([#781](https://github.com/reubeno/brush/pull/781))
+- *(deps)* Bump the cargo group with 2 updates ([#807](https://github.com/reubeno/brush/pull/807))
+- *(deps)* Bump the cargo group with 3 updates ([#836](https://github.com/reubeno/brush/pull/836))
+- *(deps)* Bump uuid from 1.18.1 to 1.19.0 in the cargo group ([#840](https://github.com/reubeno/brush/pull/840))
+- *(deps)* Bump the cargo group with 3 updates ([#882](https://github.com/reubeno/brush/pull/882))
+- *(deps)* Bump the cargo group with 3 updates ([#925](https://github.com/reubeno/brush/pull/925))
+- *(deps)* Bump thiserror from 2.0.17 to 2.0.18 in the cargo group ([#961](https://github.com/reubeno/brush/pull/961))
+- *(deps)* Bump the cargo group with 8 updates ([#1024](https://github.com/reubeno/brush/pull/1024))
+- *(deps)* Bump the cargo group with 4 updates ([#1032](https://github.com/reubeno/brush/pull/1032))
+- *(deps)* Bump the cargo group with 6 updates ([#1041](https://github.com/reubeno/brush/pull/1041))
+- *(deps)* Bump the cargo group with 7 updates ([#1059](https://github.com/reubeno/brush/pull/1059))
+- *(deps)* Bump the cargo group with 4 updates ([#1081](https://github.com/reubeno/brush/pull/1081))
+- *(deps)* Bump the cargo group with 4 updates ([#1088](https://github.com/reubeno/brush/pull/1088))
+- *(deps)* Bump the cargo group with 5 updates ([#1112](https://github.com/reubeno/brush/pull/1112))
+
+
+
+## [0.3.0] - 2025-11-17
+
+### 🚀 Features
+
+- Add ShellBuilder, ParserBuilder ([#651](https://github.com/reubeno/brush/pull/651))
+- [**breaking**] Refactor SourcePosition to use Arc ([#727](https://github.com/reubeno/brush/pull/727))
+- *(bind)* Extend key binding support ([#740](https://github.com/reubeno/brush/pull/740))
+- Enable custom error formatting ([#722](https://github.com/reubeno/brush/pull/722))
+- [**breaking**] Introduce SourceLocation ([#728](https://github.com/reubeno/brush/pull/728))
+- Implement fc builtin ([#739](https://github.com/reubeno/brush/pull/739))
+- Implement alternate arithmetic for syntax ([#744](https://github.com/reubeno/brush/pull/744))
+- Implement BASH_XTRACEFD ([#747](https://github.com/reubeno/brush/pull/747))
+- Revisit how ExecutionParameters layer open files atop the Shell ([#749](https://github.com/reubeno/brush/pull/749))
+- Basic support for trap EXIT ([#750](https://github.com/reubeno/brush/pull/750))
+- Implement opt-in fd inheritance from host env ([#753](https://github.com/reubeno/brush/pull/753))
+
+### 🐛 Bug Fixes
+
+- Workaround error on nightly ([#711](https://github.com/reubeno/brush/pull/711))
+- Comment unsafe blocks + better harden 1 block ([#733](https://github.com/reubeno/brush/pull/733))
+- Don't fail importing unreadable history lines ([#710](https://github.com/reubeno/brush/pull/710))
+- Tokenizer handling of here docs in quoted command substitutions ([#716](https://github.com/reubeno/brush/pull/716))
+- Address lint errors from stable + nightly ([#723](https://github.com/reubeno/brush/pull/723))
+- *(builtins)* Correct `read` var update on empty input ([#729](https://github.com/reubeno/brush/pull/729))
+- Address race conditions in basic input tests ([#730](https://github.com/reubeno/brush/pull/730))
+- Do not pass along exported-but-unset vars ([#732](https://github.com/reubeno/brush/pull/732))
+- *(builtins)* Suppress error when type -p sees no command ([#745](https://github.com/reubeno/brush/pull/745))
+- Command substitutions with large output ([#748](https://github.com/reubeno/brush/pull/748))
+- Expansion in select double-quoted parameter exprs ([#751](https://github.com/reubeno/brush/pull/751))
+- Correct expansion behavior in prompts ([#756](https://github.com/reubeno/brush/pull/756))
+- Escaping and pipeline parse issues ([#762](https://github.com/reubeno/brush/pull/762))
+
+### 🚜 Refactor
+
+- Use `Shell` builder pattern in more code ([#688](https://github.com/reubeno/brush/pull/688))
+- Os_pipe::pipe() -> std::io::pipe() ([#695](https://github.com/reubeno/brush/pull/695))
+- Extract script + function call stacks to their own modules ([#709](https://github.com/reubeno/brush/pull/709))
+- Update Shell::new() to take creation options as owned ([#689](https://github.com/reubeno/brush/pull/689))
+- Move builtins into their own crate ([#690](https://github.com/reubeno/brush/pull/690))
+- Shell struct API improvements ([#692](https://github.com/reubeno/brush/pull/692))
+- Error/result type overhaul ([#720](https://github.com/reubeno/brush/pull/720))
+- Move more platform-specific code under sys ([#735](https://github.com/reubeno/brush/pull/735))
+
+### 📚 Documentation
+
+- Update readme ([#742](https://github.com/reubeno/brush/pull/742))
+
+### 🧪 Testing
+
+- Add not-yet-passing tests for set -u and set -e ([#736](https://github.com/reubeno/brush/pull/736))
+- Add new command substitution test case ([#752](https://github.com/reubeno/brush/pull/752))
+
+### ⚙️ Miscellaneous Tasks
+
+- Run static code checks on linux + macOS too ([#678](https://github.com/reubeno/brush/pull/678))
+- Fix build error with cargo nightly ([#687](https://github.com/reubeno/brush/pull/687))
+- *(msrv)* [**breaking**] Upgrade MSRV to 1.87.0 ([#693](https://github.com/reubeno/brush/pull/693))
+- Fix benchmark execution ([#691](https://github.com/reubeno/brush/pull/691))
+- Update dependencies ([#696](https://github.com/reubeno/brush/pull/696))
+- Update dependencies ([#757](https://github.com/reubeno/brush/pull/757))
+- Add brush crate + docs publishing ([#760](https://github.com/reubeno/brush/pull/760))
+
+### Build
+
+- *(deps)* Bump procfs from 0.17.0 to 0.18.0 in the cargo group across 1 directory ([#671](https://github.com/reubeno/brush/pull/671))
+- *(deps)* Bump bon from 3.7.2 to 3.8.0 in the cargo group ([#698](https://github.com/reubeno/brush/pull/698))
+- *(deps)* Bump the cargo group with 4 updates ([#676](https://github.com/reubeno/brush/pull/676))
+- *(deps)* Bump serde from 1.0.221 to 1.0.223 in the cargo group ([#680](https://github.com/reubeno/brush/pull/680))
+- *(deps)* Bump the cargo group with 5 updates ([#684](https://github.com/reubeno/brush/pull/684))
+- *(deps)* Bump the cargo group with 3 updates ([#686](https://github.com/reubeno/brush/pull/686))
+- *(deps)* Bump the cargo group with 5 updates ([#708](https://github.com/reubeno/brush/pull/708))
+- *(deps)* Bump the cargo group with 8 updates ([#713](https://github.com/reubeno/brush/pull/713))
+- *(deps)* Bump the cargo group with 3 updates ([#755](https://github.com/reubeno/brush/pull/755))
+
+
+
+## [0.2.23] - 2025-08-30
+
+### 🐛 Bug Fixes
+
+- *(cmdline)* Correct exit code for `--version` + `--help` ([#667](https://github.com/reubeno/brush/pull/667))
+
+
+## [0.2.22] - 2025-08-29
+
+### 🚀 Features
+
+- *(diag)* Add minimal miette support to parser ([#648](https://github.com/reubeno/brush/pull/648))
+
+### 🐛 Bug Fixes
+
+- Exclude bind-bound commands from history ([#650](https://github.com/reubeno/brush/pull/650))
+- *(cmdline)* Improve error handling for unknown cmdline options ([#656](https://github.com/reubeno/brush/pull/656))
+
+### 📚 Documentation
+
+- Update readme ([#657](https://github.com/reubeno/brush/pull/657))
+
+### ⚙️ Miscellaneous Tasks
+
+- Additional clippy fixes ([#661](https://github.com/reubeno/brush/pull/661))
+- Downgrade homedir ([#662](https://github.com/reubeno/brush/pull/662))
+- Address warnings on windows targets ([#663](https://github.com/reubeno/brush/pull/663))
+
+### Build
+
+- *(deps)* Bump the cargo group with 4 updates ([#654](https://github.com/reubeno/brush/pull/654))
+- *(deps)* Bump the cargo group with 4 updates ([#659](https://github.com/reubeno/brush/pull/659))
+- *(deps)* Bump tracing-subscriber from 0.3.19 to 0.3.20 in the cargo group ([#664](https://github.com/reubeno/brush/pull/664))
+
+
+## [0.2.21] - 2025-08-13
+
+### 🚀 Features
+
+- *(history)* Implement history builtin ([#599](https://github.com/reubeno/brush/pull/599))
+
+### 🐛 Bug Fixes
+
+- *(parser)* Resolve issue with parser confusing subshell for arith expr ([#624](https://github.com/reubeno/brush/pull/624))
+- *(expansion)* Support broader set of nested brace expansions ([#625](https://github.com/reubeno/brush/pull/625))
+- Correct obvious string indexing errors ([#641](https://github.com/reubeno/brush/pull/641))
+- Fixes for preexec-style bash extensions ([#643](https://github.com/reubeno/brush/pull/643))
+- Prepare tests to run against bash-5.3 ([#610](https://github.com/reubeno/brush/pull/610))
+- *(unset)* Correct unset of associative array element ([#626](https://github.com/reubeno/brush/pull/626))
+- *(declare)* Refine varname validation ([#629](https://github.com/reubeno/brush/pull/629))
+- Hyphenated script args ([#630](https://github.com/reubeno/brush/pull/630))
+- Special case for command subst ([#632](https://github.com/reubeno/brush/pull/632))
+
+### 📚 Documentation
+
+- *(readme)* Update Arch Linux install instructions ([#604](https://github.com/reubeno/brush/pull/604))
+- Adds homebrew section to README installation instructions ([#638](https://github.com/reubeno/brush/pull/638))
+
+### ⚙️ Miscellaneous Tasks
+
+- Publish license notices in CD flow ([#622](https://github.com/reubeno/brush/pull/622))
+- Update dependencies ([#623](https://github.com/reubeno/brush/pull/623))
+- Cleanup allow attributes, switch to expect where possible ([#642](https://github.com/reubeno/brush/pull/642))
+
+### Build
+
+- *(deps)* Bump indenter from 0.3.3 to 0.3.4 in the cargo group ([#627](https://github.com/reubeno/brush/pull/627))
+- *(deps)* Bump the cargo group with 3 updates ([#612](https://github.com/reubeno/brush/pull/612))
+- *(deps)* Bump the cargo group with 2 updates ([#613](https://github.com/reubeno/brush/pull/613))
+- *(deps)* Bump the cargo group with 2 updates ([#619](https://github.com/reubeno/brush/pull/619))
+- *(deps)* Bump the cargo group across 1 directory with 5 updates ([#609](https://github.com/reubeno/brush/pull/609))
+- *(deps)* Bump tokio from 1.46.1 to 1.47.0 in the cargo group ([#616](https://github.com/reubeno/brush/pull/616))
+- *(deps)* Bump the cargo group with 2 updates ([#621](https://github.com/reubeno/brush/pull/621))
+
+
+## [0.2.20] - 2025-07-04
+
+### 🚀 Features
+
+- *(api)* API usability improvements for `Shell::invoke_function` (#596)
+- Enable -o/+o on brush command line (#590)
+
+### 🐛 Bug Fixes
+
+- *(dot)* Only shadow args when some provided to `source` (#582)
+
+### 🚜 Refactor
+
+- *(ShellValue)* Take in an `Into` (#598)
+
+### 📚 Documentation
+
+- README.md installation updates (#580)
+- Update README.md badges (#588)
+
+### 🧪 Testing
+
+- Tag test binary dependencies (#585)
+- Add test cases for open issues (#587)
+- Add not-yet-passing history tests (#591)
+
+### ⚙️ Miscellaneous Tasks
+
+- Update dependencies + deny policy (#586)
+- Remove unneeded dev deps from 'test-with' (#594)
+- Update dependencies (#601)
+
+### Build
+
+- *(deps)* Bump test-with from 0.15.1 to 0.15.2 in the cargo group (#593)
+
+
+## [0.2.19] - 2025-06-25
+
+### 🚀 Features
+
+- *(AndOrList)* Add iteration abilities (#512)
+- Generic Shell functions to parse a script from bytes (#509)
+- Ability to clear all functions from shell environment (#546)
+- *(vars)* Implement correct updating for -u/-c/-l vars (#529)
+- *(env)* Introduce BRUSH_VERSION variable (#531)
+- Enable cargo-binstall to work with brush (#536)
+- *(parser)* Add gettext enabled quotes (#446)
+- *(printf)* Replace printf impl with uucore wrapper (#552)
+- *(args)* Add --rcfile command-line option (#568)
+
+### 🐛 Bug Fixes
+
+- *(vars)* Ensure effective GID is first in GROUPS (#526)
+- *(traps)* Add stub definition for RETURN trap (#559)
+- *(typeset)* Mark typeset as a declaration builtin (#517)
+- *(tokenizer)* Correctly treat $( (...) ) as a cmd substitution (#521)
+- *(backquote)* Correctly handle trailing backslash in backquoted command (#524)
+- *(test)* Reuse -ef, -nt, -ot support (#525)
+- *(arithmetic)* Permit space after unary operators (#527)
+- *(tokenizer)* Correctly handle here docs terminated by EOF (#551)
+- *(interactive)* Fix behavior of cmds piped to stdin (#539)
+- *(functions)* Allow func names to contain slashes (#560)
+- *(tokenizer)* Handle escaped single-quote in ANSI-C quoted string (#561)
+- *(arithmetic)* Correct left shift handling (#562)
+- *(local)* Enable use of local to detect function (#565)
+- *(expansion)* Handle signed numbers in brace-expansion ranges (#566)
+- *(redirection)* Assorted fixes to redirection (#567)
+- *(prompt)* Implement `\A` (#569)
+- *(expansion)* Correct ${!PARAM@...} (#570)
+- *(expansion)* Fix parsing escaped single-quotes in ANSI-C strs (#571)
+- *(for/case)* Allow reserved words in for word lists (#578)
+
+### 🚜 Refactor
+
+- *(parser)* Abstract parse errors (#574)
+
+### ⚡ Performance
+
+- Remove redundant lookups in path searching (#573)
+
+### 🧪 Testing
+
+- *(parser)* Enable serde::Serialize on AST et al. for test targets (#544)
+- *(tokenizer)* Adopt insta for tokenizer tests (#550)
+- *(parser)* Start using insta crate for snapshot-testing parser (#545)
+
+### ⚙️ Miscellaneous Tasks
+
+- Upgrade MSRV to 1.85.0 (#553)
+- Upgrade crates to Rust 2024 edition (#554)
+- Enable more lints + fixes (#555)
+- Upgrade dependencies (#556)
+
+### Build
+
+- *(deps)* Bump pprof from 0.14.0 to 0.15.0 in the cargo group (#542)
+- *(deps)* Bump the cargo group with 3 updates (#522)
+
+
+## [0.2.18] - 2025-05-22
+
+### 🚀 Features
+
+- *(apply_unary_predicate_to_str)* Implement `-R` operand
+- *(apply_binary_predicate)* Implement `-ef` operand
+- *(apply_binary_predicate)* Implement `-nt` operand
+- *(ShellValue)* Add `is_set` method
+- *(apply_binary_predicate)* Implement `-ot` operand
+- *(Shell)* Add methods to add environmental variables and builtins ([#447](https://github.com/reubeno/brush/pull/447))
+- Implement {cd,pwd} -{L,P} ([#458](https://github.com/reubeno/brush/pull/458))
+- *(builtins)* Implement initial (partial) `bind -x` support ([#478](https://github.com/reubeno/brush/pull/478))
+- *(mapfile)* Register also as readarray ([#486](https://github.com/reubeno/brush/pull/486))
+- *(funcs)* Implement function exporting/importing ([#492](https://github.com/reubeno/brush/pull/492))
+- *(mapfile)* Implement `-n`, `-d`, `-s` ([#490](https://github.com/reubeno/brush/pull/490))
+- *(ulimit)* Implement ulimit builtin ([#482](https://github.com/reubeno/brush/pull/482))
+
+### 🐛 Bug Fixes
+
+- *(break,continue)* Use `default_value_t` for flag values ([#493](https://github.com/reubeno/brush/pull/493))
+- *(help)* Better compact argument help ([#499](https://github.com/reubeno/brush/pull/499))
+- *(tests)* Wrong boolean operator
+- *(trap)* Handle '-' unregistration syntax ([#452](https://github.com/reubeno/brush/pull/452))
+- *(complete)* Fixes + tests for "declare -r" ([#462](https://github.com/reubeno/brush/pull/462))
+- Enable unnameable_types lint and mitigate errors ([#459](https://github.com/reubeno/brush/pull/459))
+- *(builtin)* Fix issues with 'builtin' invoking declaration builtins ([#466](https://github.com/reubeno/brush/pull/466))
+- *(declare)* Suppress errors for non-existent functions in declare -f / -F ([#467](https://github.com/reubeno/brush/pull/467))
+- *(expansion)* Allow negative subscripts with indexed arrays + slices ([#468](https://github.com/reubeno/brush/pull/468))
+- Allow "bind" usage in .bashrc ([#485](https://github.com/reubeno/brush/pull/485))
+- *(expansion)* Fix issues parsing backquoted commands nested in single/double-quoted strings ([#491](https://github.com/reubeno/brush/pull/491))
+- Fix typos + add spell-checking PR check ([#501](https://github.com/reubeno/brush/pull/501))
+
+### 📚 Documentation
+
+- Update readme ([#481](https://github.com/reubeno/brush/pull/481))
+- Add discord invite to readme ([#494](https://github.com/reubeno/brush/pull/494))
+
+### 🧪 Testing
+
+- *(extended-tests)* Explicitly set modified date on test files ([#453](https://github.com/reubeno/brush/pull/453))
+- Add compat test for $_ ([#480](https://github.com/reubeno/brush/pull/480))
+- Add known-failing tests to reproduce reported issues ([#483](https://github.com/reubeno/brush/pull/483))
+
+### ⚙️ Miscellaneous Tasks
+
+- Enable (and fix) current checks across all workspace crates ([#457](https://github.com/reubeno/brush/pull/457))
+- *(extended_tests)* Add `-ef`, `-nt`, and `-ot`
+- Upgrade dependencies: reedline, nix, clap, thiserror, etc. ([#456](https://github.com/reubeno/brush/pull/456))
+- Better log + connect unimpl functionality with GH issues ([#476](https://github.com/reubeno/brush/pull/476))
+
+### Build
+
+- *(deps)* Bump the cargo group with 3 updates ([#461](https://github.com/reubeno/brush/pull/461))
+- *(deps)* Bump the cargo group with 2 updates ([#487](https://github.com/reubeno/brush/pull/487))
+
+### Ref
+
+- *(Shell)* Turn `&Path` parameters into `AsRef` ([#448](https://github.com/reubeno/brush/pull/448))
+
+
+## [0.2.17] - 2025-04-24
+
+### 🚀 Features
+
+- *(Shell)* Add `get_env_var` method for more generic variable returns (#438)
+
+### 🐛 Bug Fixes
+
+- Honor COMP_WORDBREAKS in completion tokenization (#407)
+- Handle complete builtin run without options (#435)
+
+### 📚 Documentation
+
+- Add instructions for installing from the AUR (#433)
+
+### 🧪 Testing
+
+- Implement --skip in brush-compat-tests harness (#432)
+
+### ⚙️ Miscellaneous Tasks
+
+- *(Shell)* Use relaxed typing for string input (#437)
+- Enable building for wasm32-unknown-unknown (#425)
+
+### Build
+
+- *(deps)* Bump rand from 0.9.0 to 0.9.1 in the cargo group (#431)
+- *(deps)* Bump the cargo group with 3 updates (#426)
+- *(deps)* Bump the cargo group with 2 updates (#427)
+
+
+## [0.2.16] - 2025-03-25
+
+### 🚀 Features
+
+- *(arithmetic)* Support explicit base#literal in arithmetic (#388)
+- Implement `command -p` (#402)
+
+### 🐛 Bug Fixes
+
+- Default PS1 and PS2 in interactive mode (#390)
+- *(builtins)* Implement command-less exec semantics with open fds (#384)
+- *(builtins)* Correct read handling of IFS/space (#385)
+- *(extglob)* Correct handling of extglobs with empty branches (#386)
+- Correct path tests on empty strings (#391)
+- Allow newline in empty array assignment (#405)
+- Improve panic handling output (#409)
+- *(regex)* Enable multiline mode for extended test regexes (#416)
+- Parse '#' as char only if inside a variable expansion (#418)
+
+### 📚 Documentation
+
+- Symlink licenses under crate dirs (#400)
+
+### 🧪 Testing
+
+- Add more linux distros to test matrix (#412)
+- Enable testing on nixos/nix container image (#413)
+
+### ⚙️ Miscellaneous Tasks
+
+- Upgrade cached crate (#398)
+- Rewrite dir diffing test code to avoid deps
+
+### Build
+
+- *(deps)* Bump the cargo group with 2 updates (#393)
+- *(deps)* Bump whoami from 1.5.2 to 1.6.0 in the cargo group (#423)
+- *(deps)* Bump the cargo group with 2 updates (#389)
+- *(deps)* Bump serde from 1.0.218 to 1.0.219 in the cargo group (#401)
+- *(deps)* Bump the cargo group with 2 updates (#410)
+
+
+## [0.2.15] - 2025-02-03
+
+### 🚀 Features
+
+- *(continue)* Implement continue n for n >= 2 ([#326](https://github.com/reubeno/brush/pull/326))
+- *(options)* Implement dotglob semantics ([#332](https://github.com/reubeno/brush/pull/332))
+- *(options)* Implement "set -t" ([#333](https://github.com/reubeno/brush/pull/333))
+- *(options)* Implement "set -a" ([#336](https://github.com/reubeno/brush/pull/336))
+- *(env)* Introduce dynamic variables ([#360](https://github.com/reubeno/brush/pull/360))
+
+### 🐛 Bug Fixes
+
+- *(builtins)* Skip unenumerable vars in set builtin ([#322](https://github.com/reubeno/brush/pull/322))
+- *(expansion)* Handle negative substring offset ([#372](https://github.com/reubeno/brush/pull/372))
+- *(completion)* Better handle native errors in completion funcs ([#373](https://github.com/reubeno/brush/pull/373))
+- *(builtins)* Correct parsing of bind positional arg ([#381](https://github.com/reubeno/brush/pull/381))
+- *(patterns)* Fix incorrect parse of char ranges ([#323](https://github.com/reubeno/brush/pull/323))
+- *(exit)* Correct exit semantics in various compund statements ([#347](https://github.com/reubeno/brush/pull/347))
+- *(for)* Correct semantics for "for" without "in" ([#348](https://github.com/reubeno/brush/pull/348))
+- Correct semantics of = in non-extended test commands ([#349](https://github.com/reubeno/brush/pull/349))
+- *(return)* Error if return used outside sourced script or function ([#350](https://github.com/reubeno/brush/pull/350))
+- *(arithmetic)* Recursively evaluate var references ([#351](https://github.com/reubeno/brush/pull/351))
+- *(arithmetic)* Fixes for nested parenthesis parsing in arithmetic ([#353](https://github.com/reubeno/brush/pull/353))
+- *(builtins)* Fix set builtin handling of - and -- ([#354](https://github.com/reubeno/brush/pull/354))
+- *(builtins)* Do not interpret --help in command builtin command args ([#355](https://github.com/reubeno/brush/pull/355))
+- *(builtins)* Correct more 'set' argument parsing ([#356](https://github.com/reubeno/brush/pull/356))
+- *(variables)* More correct handling of integer variables ([#357](https://github.com/reubeno/brush/pull/357))
+- *(redirection)* Make sure redirection fd + operator are contiguous ([#359](https://github.com/reubeno/brush/pull/359))
+- Better error when cwd is gone ([#370](https://github.com/reubeno/brush/pull/370))
+- *(builtins)* Fix read builtin ignoring tab chars ([#371](https://github.com/reubeno/brush/pull/371))
+- Propagate execution parameters more thoroughly ([#374](https://github.com/reubeno/brush/pull/374))
+- *(redirection)* Allow continuing past redir errors ([#375](https://github.com/reubeno/brush/pull/375))
+
+### ⚡ Performance
+
+- Remove unneeded string cloning for arithmetic eval ([#324](https://github.com/reubeno/brush/pull/324))
+- Simplify export enumeration ([#363](https://github.com/reubeno/brush/pull/363))
+- Skip word parsing if no expansion required ([#365](https://github.com/reubeno/brush/pull/365))
+- Minor optimizations for shell create + command run ([#362](https://github.com/reubeno/brush/pull/362))
+
+### 🧪 Testing
+
+- *(perf)* Update tokenizer/parser benchmarks ([#321](https://github.com/reubeno/brush/pull/321))
+- Resolve false errors about side effects in bash-completion tests ([#379](https://github.com/reubeno/brush/pull/379))
+
+### ⚙️ Miscellaneous Tasks
+
+- Remove some unneeded `pub(crate)` visibility annotations ([#346](https://github.com/reubeno/brush/pull/346))
+- Remove unneeded result wrappings ([#367](https://github.com/reubeno/brush/pull/367))
+- Remove a few object clones ([#368](https://github.com/reubeno/brush/pull/368))
+- Update readme ([#331](https://github.com/reubeno/brush/pull/331))
+- Add pattern and expansion tests to track newly filed issues ([#330](https://github.com/reubeno/brush/pull/330))
+- Minor cleanups and test additions ([#364](https://github.com/reubeno/brush/pull/364))
+- Fix rng warnings ([#378](https://github.com/reubeno/brush/pull/378))
+
+### Build
+
+- *(deps)* Bump indexmap from 2.7.0 to 2.7.1 in the cargo group ([#334](https://github.com/reubeno/brush/pull/334))
+- *(deps)* Bump the cargo group across 1 directory with 4 updates ([#320](https://github.com/reubeno/brush/pull/320))
+- *(deps)* Bump the cargo group with 3 updates ([#376](https://github.com/reubeno/brush/pull/376))
+
+
+## [0.2.14] - 2025-01-10
+
+### 🚀 Features
+
+- *(prompts)* Enable PS0, custom right-side prompts, more ([#278](https://github.com/reubeno/brush/pull/278))
+- *(completion)* Programmable completion support for filters + commands
+- *(non-posix)* Implement `time` keyword ([#310](https://github.com/reubeno/brush/pull/310))
+- *(builtins)* Implement suspend ([#311](https://github.com/reubeno/brush/pull/311))
+- *(set)* Implement nullglob option ([#279](https://github.com/reubeno/brush/pull/279))
+- *(set)* Implement nocaseglob + nocasematch options ([#282](https://github.com/reubeno/brush/pull/282))
+- *(builtins)* Add minimal mapfile + bind impls
+- *(debug)* Improved function tracing capabilities
+- *(options)* Implement lastpipe option
+- Implement brace expansion ([#290](https://github.com/reubeno/brush/pull/290))
+- *(options)* Implement noclobber option (a.k.a. -C) ([#291](https://github.com/reubeno/brush/pull/291))
+- *(builtins)* Implement more of kill builtin ([#305](https://github.com/reubeno/brush/pull/305))
+- *(builtins)* Implement times builtin ([#309](https://github.com/reubeno/brush/pull/309))
+
+### 🐛 Bug Fixes
+
+- Correct sh mode vs posix mode confusion for syntax extensions
+- Assorted non-fatal clippy warnings ([#274](https://github.com/reubeno/brush/pull/274))
+- *(builtins)* Correct behavior of set builtin with no args
+- More consistently honor shell options when invoking the tokenizer
+- Update COMP_WORDBREAKS default value
+- Honor extglob for expansion transformations
+- Sync PWD with actual workdir on launch
+- *(jobs)* Only report job status when job control option is enabled ([#306](https://github.com/reubeno/brush/pull/306))
+- Stop incorrectly parsing assignment as function def ([#273](https://github.com/reubeno/brush/pull/273))
+- Multiple issues blocking docker cmd completion ([#275](https://github.com/reubeno/brush/pull/275))
+- Improve substring ops with multi-byte chars ([#280](https://github.com/reubeno/brush/pull/280))
+- Better handle escape chars in pattern bracket exprs ([#281](https://github.com/reubeno/brush/pull/281))
+- *(completion)* Multiple fixes for compgen builtin usage
+- *(regex)* Work around incompatibilities between shell + rust regexes
+- *(extendedtests)* Add missing arithmetic eval in extended tests
+- *(command)* Handle sending basic command errors to redirected stderr
+- Improve accuracy of negative extglobs
+- Implement date and time in prompts ([#298](https://github.com/reubeno/brush/pull/298))
+- *(completion)* Handle -o {default,dirnames,plusdirs} ([#300](https://github.com/reubeno/brush/pull/300))
+- *(expansion)* Correct length for 1-element arrays ([#316](https://github.com/reubeno/brush/pull/316))
+- Correct issues with `!` extglobs and compgen -X ([#317](https://github.com/reubeno/brush/pull/317))
+
+### 📚 Documentation
+
+- Update README to reflect test expansion
+
+### ⚡ Performance
+
+- Cache parsing for arithmetic expressions ([#301](https://github.com/reubeno/brush/pull/301))
+- Remove unneeded async from arithmetic eval ([#312](https://github.com/reubeno/brush/pull/312))
+- Remove setup operations from microbenchmarks ([#307](https://github.com/reubeno/brush/pull/307))
+- Reimplement colon command as a "simple builtin" ([#315](https://github.com/reubeno/brush/pull/315))
+
+### 🧪 Testing
+
+- *(completion)* Add another completion test
+- *(completion)* Enable use of pexpect et al. with basic input backend
+
+### ⚙️ Miscellaneous Tasks
+
+- Update comments
+- Improve tracing for completion function invocation
+- Remove unneeded helper code
+- Address warnings ([#313](https://github.com/reubeno/brush/pull/313))
+
+### Build
+
+- *(deps)* Bump the cargo group with 3 updates ([#285](https://github.com/reubeno/brush/pull/285))
+- *(deps)* Bump the cargo group with 4 updates ([#289](https://github.com/reubeno/brush/pull/289))
+- *(deps)* Bump the cargo group with 3 updates ([#294](https://github.com/reubeno/brush/pull/294))
+- *(deps)* Bump anyhow from 1.0.94 to 1.0.95 in the cargo group ([#297](https://github.com/reubeno/brush/pull/297))
+- *(deps)* Bump the cargo group with 2 updates ([#299](https://github.com/reubeno/brush/pull/299))
+- *(deps)* Bump the cargo group with 2 updates ([#304](https://github.com/reubeno/brush/pull/304))
+
+
+## [0.2.13] - 2024-11-26
+
+### 🚀 Features
+
+- *(ast)* Derive `PartialEq` and `Eq` for testing ([#259](https://github.com/reubeno/brush/pull/259))
+
+### 🐛 Bug Fixes
+
+- Correct parsing of parens in arithmetic command ([#270](https://github.com/reubeno/brush/pull/270))
+
+### ⚙️ Miscellaneous Tasks
+
+- Upgrade dependencies ([#271](https://github.com/reubeno/brush/pull/271))
+
+### Build
+
+- *(deps)* Bump the cargo group with 3 updates ([#258](https://github.com/reubeno/brush/pull/258))
+- *(deps)* Bump the cargo group with 7 updates ([#267](https://github.com/reubeno/brush/pull/267))
+
+
+## [0.2.12] - 2024-11-03
+
+### 🚀 Features
+
+- Implement support for ;;& and ;& in case items ([#223](https://github.com/reubeno/brush/pull/223))
+- Implement `|&` extension ([#240](https://github.com/reubeno/brush/pull/240))
+- Implement `kill -l` ([#221](https://github.com/reubeno/brush/pull/221))
+- Implement `|&` for function declarations ([#244](https://github.com/reubeno/brush/pull/244))
+
+### 🐛 Bug Fixes
+
+- Omit dirs from executable searches ([#236](https://github.com/reubeno/brush/pull/236))
+- Handle PS2 prompts that require prompt-expansion ([#239](https://github.com/reubeno/brush/pull/239))
+- Allow usually-operator chars in regex parens ([#224](https://github.com/reubeno/brush/pull/224))
+- Assorted correctness issues in getopts builtin ([#225](https://github.com/reubeno/brush/pull/225))
+- Assorted completion-related issues ([#226](https://github.com/reubeno/brush/pull/226))
+- String replacement with slashes ([#231](https://github.com/reubeno/brush/pull/231))
+- Correct pattern removal expansions on arrays ([#232](https://github.com/reubeno/brush/pull/232))
+- *(completion)* Fix -- handling in getopts ([#235](https://github.com/reubeno/brush/pull/235))
+- *(completion)* Correct behavior of slice past end of array ([#237](https://github.com/reubeno/brush/pull/237))
+- Support here documents in command substitutions ([#255](https://github.com/reubeno/brush/pull/255))
+
+### 🧪 Testing
+
+- Run completion tests using bash-completion 2.14.0 ([#238](https://github.com/reubeno/brush/pull/238))
+- Add os-targeted integration tests ([#241](https://github.com/reubeno/brush/pull/241))
+
+### ⚙️ Miscellaneous Tasks
+
+- Upgrade crate dependencies ([#247](https://github.com/reubeno/brush/pull/247))
+
+### Build
+
+- *(deps)* Bump the cargo group with 2 updates ([#220](https://github.com/reubeno/brush/pull/220))
+
+
+## [0.2.11] - 2024-10-18
+
+### 🚀 Features
+
+- Experimentally enable reedline as an input backend ([#186](https://github.com/reubeno/brush/pull/186))
+- Default to reedline and add syntax highlighting support ([#187](https://github.com/reubeno/brush/pull/187))
+- Add a panic handler via human-panic ([#191](https://github.com/reubeno/brush/pull/191))
+- Several fixes for bash-completion + tests ([#192](https://github.com/reubeno/brush/pull/192))
+- Implement `cd -` ([#201](https://github.com/reubeno/brush/pull/201))
+- Implement command hashing ([#206](https://github.com/reubeno/brush/pull/206))
+
+### 🐛 Bug Fixes
+
+- Deduplicate completion candidates ([#189](https://github.com/reubeno/brush/pull/189))
+- Cleanup transient completion variables ([#213](https://github.com/reubeno/brush/pull/213))
+- Allow newlines in extended test exprs ([#188](https://github.com/reubeno/brush/pull/188))
+- Fixes for short-circuit precedence + parameter expr replacement ([#193](https://github.com/reubeno/brush/pull/193))
+- Workarounds for edge word parsing cases ([#194](https://github.com/reubeno/brush/pull/194))
+- Assorted completion issues with ~ and vars ([#199](https://github.com/reubeno/brush/pull/199))
+- Slight compat improvements to set -x ([#205](https://github.com/reubeno/brush/pull/205))
+- Matching newline chars in glob patterns ([#207](https://github.com/reubeno/brush/pull/207))
+- Honor IFS in read builtin ([#208](https://github.com/reubeno/brush/pull/208))
+- Correct behavior of break in arithmetic for loop ([#210](https://github.com/reubeno/brush/pull/210))
+- Address issues with array unset ([#211](https://github.com/reubeno/brush/pull/211))
+- Handle expansion in here documents ([#212](https://github.com/reubeno/brush/pull/212))
+
+### 📚 Documentation
+
+- Update readme ([#182](https://github.com/reubeno/brush/pull/182))
+- Update readme with new links ([#204](https://github.com/reubeno/brush/pull/204))
+
+### 🧪 Testing
+
+- Enable setting min oracle version on tests ([#184](https://github.com/reubeno/brush/pull/184))
+
+### ⚙️ Miscellaneous Tasks
+
+- Where possible replace `async-trait` with native async trait support in 1.75+ ([#197](https://github.com/reubeno/brush/pull/197))
+
+### Build
+
+- *(deps)* Bump futures from 0.3.30 to 0.3.31 in the cargo group ([#190](https://github.com/reubeno/brush/pull/190))
+- Leave rustyline disabled by default ([#196](https://github.com/reubeno/brush/pull/196))
+- *(deps)* Bump the cargo group with 4 updates ([#203](https://github.com/reubeno/brush/pull/203))
+- Remove rustyline support ([#216](https://github.com/reubeno/brush/pull/216))
+
+
+## [0.2.10] - 2024-09-30
+
+### 🐛 Bug Fixes
+
+- Allow source to be used with process substitution ([#175](https://github.com/reubeno/brush/pull/175))
+- Address multiple issues with foreground controls for pipeline commands ([#180](https://github.com/reubeno/brush/pull/180))
+
+### 🧪 Testing
+
+- Move to cargo nextest ([#176](https://github.com/reubeno/brush/pull/176))
+- Correctly report skipped tests for nextest ([#178](https://github.com/reubeno/brush/pull/178))
+- Convert more test skips to known failures ([#179](https://github.com/reubeno/brush/pull/179))
+
+### Build
+
+- *(deps)* Bump the cargo group with 2 updates ([#177](https://github.com/reubeno/brush/pull/177))
+
+
+## [0.2.9] - 2024-09-26
+
+### 🚀 Features
+
+- Launch processes in their own process groups ([#166](https://github.com/reubeno/brush/pull/166))
+
+### 🐛 Bug Fixes
+
+- Posix compliant argument parsing for `-c` mode ([#147](https://github.com/reubeno/brush/pull/147))
+
+### 🧪 Testing
+
+- Add more basic interactive tests ([#168](https://github.com/reubeno/brush/pull/168))
+- Bring up macos testing ([#172](https://github.com/reubeno/brush/pull/172))
+
+### Build
+
+- *(deps)* Bump thiserror from 1.0.63 to 1.0.64 in the cargo group ([#167](https://github.com/reubeno/brush/pull/167))
+- Temporarily disable failing test ([#170](https://github.com/reubeno/brush/pull/170))
+- Refactor PR workflow to better support multi-platform build + test ([#169](https://github.com/reubeno/brush/pull/169))
+
+
+## [0.2.8] - 2024-09-17
+
+### 🐛 Bug Fixes
+
+- Implement ~USER syntax ([#160](https://github.com/reubeno/brush/pull/160))
+- Compgen needs to expand target arg ([#162](https://github.com/reubeno/brush/pull/162))
+- Do not invoke debug traps during completion funcs ([#163](https://github.com/reubeno/brush/pull/163))
+- Disable flaky test until it can be root-caused
+
+### 📚 Documentation
+
+- Generate man page via xtask ([#157](https://github.com/reubeno/brush/pull/157))
+
+### ⚡ Performance
+
+- Short-term optimization for common-case printf
+
+### ⚙️ Miscellaneous Tasks
+
+- Extract InteractiveShell as trait + refactor ([#159](https://github.com/reubeno/brush/pull/159))
+
+### Build
+
+- *(deps)* Bump tokio from 1.39.3 to 1.40.0 in the cargo group ([#156](https://github.com/reubeno/brush/pull/156))
+- *(deps)* Bump the cargo group with 6 updates ([#158](https://github.com/reubeno/brush/pull/158))
+- *(deps)* Bump the cargo group with 2 updates ([#161](https://github.com/reubeno/brush/pull/161))
+
+
+## [0.2.7] - 2024-09-01
+
+### 🚀 Features
+
+- Move MSRV up to 1.75.0 ([#139](https://github.com/reubeno/brush/pull/139))
+
+### 🐛 Bug Fixes
+
+- Correct echo -e escape expansion for \x sequences ([#143](https://github.com/reubeno/brush/pull/143))
+- Disable displaying tracing target ([#140](https://github.com/reubeno/brush/pull/140))
+- Correct multiple issues with process substitution + redirection ([#145](https://github.com/reubeno/brush/pull/145))
+
+### Build
+
+- *(deps)* Bump tokio from 1.39.1 to 1.39.2 in the cargo group ([#141](https://github.com/reubeno/brush/pull/141))
+- *(deps)* Bump the cargo group with 3 updates ([#148](https://github.com/reubeno/brush/pull/148))
+- *(deps)* Bump serde from 1.0.204 to 1.0.206 in the cargo group ([#150](https://github.com/reubeno/brush/pull/150))
+- *(deps)* Bump the cargo group with 2 updates ([#152](https://github.com/reubeno/brush/pull/152))
+- *(deps)* Bump serde from 1.0.208 to 1.0.209 in the cargo group ([#154](https://github.com/reubeno/brush/pull/154))
+
+
+## [0.2.6] - 2024-07-23
+
+### 🐛 Bug Fixes
+
+- Correct relative path resolution cases
+- Relative path completion fixes ([#137](https://github.com/reubeno/brush/pull/137))
+
+
+## [0.2.5] - 2024-07-23
+
+### 🐛 Bug Fixes
+
+- Build error outside git ([#134](https://github.com/reubeno/brush/pull/134))
+
+### Build
+
+- *(deps)* Bump the cargo group with 2 updates ([#133](https://github.com/reubeno/brush/pull/133))
+
+
+## [0.2.4] - 2024-07-19
+
+### 🚀 Features
+
+- Initial support for non-linux
+- Enable simpler builtins implemented outside brush ([#130](https://github.com/reubeno/brush/pull/130))
+- Get building on windows and wasm-wasip1 targets ([#116](https://github.com/reubeno/brush/pull/116))
+- Add brushctl builtin, seed with event toggling support
+
+### 🐛 Bug Fixes
+
+- Absorb breaking change in homedir crate
+- Clippy and check warnings ([#123](https://github.com/reubeno/brush/pull/123))
+- Correct completion fallback logic when spec matches but 0 results ([#125](https://github.com/reubeno/brush/pull/125))
+- Various build warnings on windows build ([#126](https://github.com/reubeno/brush/pull/126))
+- Exclude tags from git version info ([#115](https://github.com/reubeno/brush/pull/115))
+
+### 📚 Documentation
+
+- Update readme ([#127](https://github.com/reubeno/brush/pull/127))
+
+### ⚙️ Miscellaneous Tasks
+
+- Merge builtin and builtins modules
+- Update comments ([#129](https://github.com/reubeno/brush/pull/129))
+
+### Build
+
+- *(deps)* Bump the cargo group across 1 directory with 5 updates
+
+
+## [0.2.3] - 2024-07-03
+
+### 🚀 Features
+
+- Enable -O and +O on command line (#105)
+- Start using cargo-fuzz for testing (#106)
+- Enable fuzz-testing arithmetic eval (#108)
+- Include more details in version info (#112)
+
+### 🐛 Bug Fixes
+
+- Correct expansion when PWD is / (#96)
+- Ensure parser error actually impls Error (#98)
+- Realign newline parsing with spec (#99)
+- Correct handling of unterminated expansions (#101)
+- Add &>> implementation (#103)
+- Correct metadata for fuzz crate (#107)
+- Resolve assorted arithmetic eval issues (#110)
+- Correct ** overflow behavior (#111)
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Cargo.lock (#113)
+- Release
+
+### Build
+
+- Take targeted dependency updates (#93)
+- Update config (#97)
+
+## [0.2.2] - 2024-06-19
+
+### 🚀 Features
+
+- Implement 'command' builtin (#77)
+- Add stubs for help man page generation
+- Fill out read builtin impl
+- Rework here doc files (#85)
+- Set + validate intentional MSRV (1.72.0) (#86)
+- Add basic changelog
+- Add basic changelog (#87)
+
+### 🐛 Bug Fixes
+
+- Compgen -W expansion (#78)
+- Don't split completions that aren't file paths (#79)
+- Allow interrupting read builtin, run pipeline cmds in subshell (#81)
+- Add missing flush calls
+- Tweak manifests to work with release flow (#89)
+- Ensure brush-core builds outside workspace (#90)
+
+### 📚 Documentation
+
+- Add crate shields to readme (#74)
+- Add missing code documentation
+
+### ⚙️ Miscellaneous Tasks
+
+- *(release)* Bump version to 0.2.0 (#88)
+
+### Build
+
+- Update dependencies
+- Adjust clippy warnings
+
+## [0.1.0] - 2024-06-11
+
+### Build
+
+- Prepare for initial release (#68)
+- Enable publishing (#71)
+
+
diff --git a/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md b/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000000..4502527269
--- /dev/null
+++ b/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md
@@ -0,0 +1,131 @@
+
+# Contributor Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the project administrators. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/local/recipes/shells/brush/source/CONTRIBUTING.md b/local/recipes/shells/brush/source/CONTRIBUTING.md
new file mode 100644
index 0000000000..1aa44eacf2
--- /dev/null
+++ b/local/recipes/shells/brush/source/CONTRIBUTING.md
@@ -0,0 +1,138 @@
+
+# Contributing to brush
+
+First off, thanks for taking the time to contribute! ❤️
+
+All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
+
+> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
+> - Star the project
+> - Send us feedback via issues or discussion
+> - Refer this project in your project's readme
+> - Post about it
+> - Tell your friends/colleagues
+
+
+## Table of Contents
+- [Code of Conduct](#code-of-conduct)
+- [I Have a Question](#i-have-a-question)
+ - [I Want To Contribute](#i-want-to-contribute)
+ - [Reporting Bugs](#reporting-bugs)
+ - [Suggesting Enhancements](#suggesting-enhancements)
+ - [Your First Code Contribution](#your-first-code-contribution)
+ - [Improving The Documentation](#improving-the-documentation)
+- [Styleguides](#styleguides)
+ - [Commit Messages](#commit-messages)
+- [Join The Project Team](#join-the-project-team)
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by the
+[brush Code of Conduct](CODE_OF_CONDUCT.md).
+By participating, you are expected to uphold this code. Please report unacceptable behavior
+to project admins.
+
+## I Have a Question
+
+> If you want to ask a question, we assume that you have read the available [Documentation](docs).
+
+Before you ask a question, it is best to search for existing [Issues](https://github.com/reubeno/brush/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
+
+If you then still feel the need to ask a question and need clarification, we recommend the following:
+
+- Open an [Issue](https://github.com/reubeno/brush/issues/new).
+- Provide as much context as you can about what you're running into.
+- Provide project and platform versions, depending on what seems relevant.
+
+We will then take care of the issue as soon as possible.
+
+## I Want To Contribute
+
+### Legal Notice
+
+> [!NOTE]
+> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
+
+### AI Policy
+
+> [!NOTE]
+> AI assistants have arrived. With them come many questions and much debate about policy, rights, appropriate usage, etc. Our evolving experiences show that they can be wielded as powerful tools, but we acknowledge that it is critical that they be used *responsibly* and thoughtfully. We set out the following principles for our project.
+
+* **Human supervision and review remain critical.** You, the human authors, are the contributors to this project. Regardless of which tools you use along the way, you are all fully accountable for your contributions. This means that you are still accountable for ensuring that you have the necessary rights to the content you submit to this project. It also remains your responsibility to review, test, understand, and vouch for all code and content that you submit. Accordingly, you must fully understand all code and other content in your contributions and be able to explain their behavior and purpose.
+
+* **Please be transparent about your use of AI.** When contributions have been significantly assisted by AI tools, we ask that you note this in your Pull Request description or commit message via an `Assisted-by:` trailer. This helps us maintain openness in our practices and aid our project in learning and understanding how these tools are being used to contribute effectively.
+
+* **Our policies and practices will evolve.** Things are moving quickly; we consider it far better to adapt as we go, learn, and iterate rather than to remain static. We remain open to your input and feedback on this policy as it evolves, and commit to engaged, respectful discussion regarding it.
+
+#### References
+
+We take inspiration (and direct language) from the emerging Fedora proposal for [policy regarding AI-assisted contributions](https://communityblog.fedoraproject.org/council-policy-proposal-policy-on-ai-assisted-contributions/) as well as from the Apache Software Foundation's [Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html).
+
+### Reporting Bugs
+
+
+#### Before Submitting a Bug Report
+
+A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
+
+- Make sure that you are using the latest version.
+- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](docs). If you are looking for support, you might want to check [this section](#i-have-a-question)).
+- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/reubeno/brush/issues?q=label%3Abug).
+- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
+- Collect information about the bug:
+ - Stack trace (Traceback)
+ - OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
+ - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
+ - Possibly your input and the output
+ - Can you reliably reproduce the issue? And can you also reproduce it with older versions?
+
+
+#### How Do I Submit a Good Bug Report?
+
+We use GitHub issues to track bugs and errors. If you run into an issue with the project:
+
+- Open an [Issue](https://github.com/reubeno/brush/issues/new).
+- Explain the behavior you would expect and the actual behavior.
+- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
+- Provide the information you collected in the previous section.
+
+Once it's filed:
+
+- The project team will label the issue accordingly.
+- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
+- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
+
+
+### Suggesting Enhancements
+
+This section guides you through submitting an enhancement suggestion for {{=it.project.name}}, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
+
+
+#### Before Submitting an Enhancement
+
+- Make sure that you are using the latest version.
+- Read the [documentation](docs) carefully and find out if the functionality is already covered, maybe by an individual configuration.
+- Perform a [search](https://github.com/reubeno/brush/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
+- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
+
+
+#### How Do I Submit a Good Enhancement Suggestion?
+
+Enhancement suggestions are tracked as [GitHub issues](https://github.com/reubeno/brush/issues).
+
+- Use a **clear and descriptive title** for the issue to identify the suggestion.
+- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
+- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
+- **Explain why this enhancement would be useful** to most {{=it.project.name}} users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
+
+### Your First Code Contribution
+We're excited to see your first contribution! We don't bite, so we'd much rather you publish something than not. If you submit your PR as a draft we're happy to give it a quick scan and provide some early input. Once the PR is marked "ready for review" we'll look through it more carefully.
+
+### Improving The Documentation
+We appreciate documentation updates as much as code updates! We try to follow the [Diátaxis](https://diataxis.fr/) approach to technical docs. The short version is we recognize the need for tutorials, how-to guides, reference material, and sometimes longer-form explanation docs--*and* we recognize the value of being intentional about organizing by those categories.
+## Styleguides
+### Commit Messages
+When you look through our repo history, you'll see commits prefixed with a tag like `feat:` or `fix:`. We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to maintain consistent readability of commits, both for humans and mechanical release processes.
+
+## Attribution
+This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
\ No newline at end of file
diff --git a/local/recipes/shells/brush/source/Cargo.toml b/local/recipes/shells/brush/source/Cargo.toml
new file mode 100644
index 0000000000..b759572c2c
--- /dev/null
+++ b/local/recipes/shells/brush/source/Cargo.toml
@@ -0,0 +1,119 @@
+[workspace]
+resolver = "3"
+members = [
+ "brush",
+ "brush-shell",
+ "brush-parser",
+ "brush-core",
+ "brush-builtins",
+ "brush-coreutils-builtins",
+ "brush-experimental-builtins",
+ "brush-interactive",
+ "brush-test-harness",
+ "fuzz",
+ "xtask",
+]
+default-members = ["brush-shell"]
+
+[workspace.package]
+authors = ["reuben olinsky"]
+categories = ["command-line-utilities", "development-tools"]
+edition = "2024"
+keywords = ["cli", "shell", "sh", "bash", "script"]
+license = "MIT"
+readme = "README.md"
+repository = "https://github.com/reubeno/brush"
+rust-version = "1.88.0"
+
+[workspace.lints.rust]
+warnings = { level = "deny" }
+future_incompatible = { level = "deny" }
+missing_docs = { level = "deny" }
+nonstandard_style = { level = "deny" }
+rust_2018_idioms = { level = "deny", priority = -1 }
+unnameable_types = "deny"
+unsafe_op_in_unsafe_fn = "deny"
+unused_attributes = "deny"
+unused_lifetimes = "deny"
+unused_macro_rules = "deny"
+# For now we allow unknown lints so we can opt into warnings that don't exist on the
+# oldest toolchain we need to support.
+unknown_lints = { level = "allow", priority = -100 }
+
+[workspace.lints.rustdoc]
+all = { level = "deny", priority = -1 }
+
+[workspace.lints.clippy]
+all = { level = "deny", priority = -1 }
+pedantic = { level = "deny", priority = -1 }
+cargo = { level = "deny", priority = -1 }
+nursery = { level = "deny", priority = -1 }
+perf = { level = "deny", priority = -1 }
+
+# Denied rules
+expect_used = "deny"
+format_push_string = "deny"
+multiple_unsafe_ops_per_block = "deny"
+panic = "deny"
+panic_in_result_fn = "deny"
+string_lit_chars_any = "deny"
+string_slice = "deny"
+tests_outside_test_module = "deny"
+todo = "deny"
+unwrap_in_result = "deny"
+unwrap_used = "deny"
+undocumented_unsafe_blocks = "deny"
+
+# Allowed rules
+bool_to_int_with_if = "allow"
+cognitive_complexity = "allow"
+collapsible_else_if = "allow"
+collapsible_if = "allow"
+if_not_else = "allow"
+if_same_then_else = "allow"
+match_same_arms = "allow"
+missing_errors_doc = "allow"
+must_use_candidate = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_closure_for_method_calls = "allow"
+redundant_else = "allow"
+redundant_pub_crate = "allow"
+result_large_err = "allow"
+similar_names = "allow"
+struct_excessive_bools = "allow"
+
+[workspace.metadata.typos]
+files.extend-exclude = [
+ # Exclude the changelog because it's dynamically generated.
+ "/CHANGELOG.md",
+ # Remove this once impending mapfile updates are merged.
+ "/brush-core/src/builtins/mapfile.rs",
+]
+
+[workspace.metadata.typos.default]
+extend-ignore-re = [
+ # -ot is a valid binary operator
+ "-ot",
+ # Ignore 2-letter string literals, which show up in testing a fair bit.
+ '"[a-zA-Z]{2}"',
+]
+
+[workspace.metadata.typos.default.extend-words]
+# These show up in tests.
+"abd" = "abd"
+"hel" = "hel"
+"ba" = "ba"
+
+# This is a specific technical name.
+"iterm" = "iterm"
+
+[profile.release]
+strip = true
+lto = "fat"
+codegen-units = 1
+panic = "abort"
+
+[profile.bench]
+inherits = "release"
+strip = "debuginfo"
diff --git a/local/recipes/shells/brush/source/LICENSE b/local/recipes/shells/brush/source/LICENSE
new file mode 100644
index 0000000000..3d3cb78667
--- /dev/null
+++ b/local/recipes/shells/brush/source/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 reuben olinsky
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/local/recipes/shells/brush/source/README.md b/local/recipes/shells/brush/source/README.md
new file mode 100644
index 0000000000..c43c294fc0
--- /dev/null
+++ b/local/recipes/shells/brush/source/README.md
@@ -0,0 +1,215 @@
+
+
+
+
+`brush` (**B**o(u)rn(e) **RU**sty **SH**ell) is a modern [bash-](https://www.gnu.org/software/bash/) and [POSIX-](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) compatible shell written in Rust. Run your existing scripts and `.bashrc` unchanged -- with syntax highlighting and auto-suggestions built in.
+
+## At a glance
+
+✅ Your existing `.bashrc` just works—aliases, functions, completions, all of it.
+✨ Syntax highlighting and auto-suggestions built in.
+🧪 Validated against bash with [~1700 compatibility tests](brush-shell/tests/cases).
+🧩 Easily embeddable in your Rust apps using `brush_core::Shell`.
+
+
+
+
+
+> ⚠️ **Not everything works yet:** `select` and some edge cases aren't supported. See the [Compatibility Reference](docs/reference/compatibility.md) for details.
+
+### Quick start:
+
+```console
+$ cargo binstall brush-shell # using cargo-binstall
+$ brew install brush # using Homebrew
+$ pacman -S brush # Arch Linux
+$ cargo install --locked brush-shell # Build from sources
+```
+
+`brush` is ready for use as a daily driver. We test every change against `bash` to keep it that way.
+
+More detailed installation instructions are available below.
+
+## ✨ Features
+
+### 🐚 `bash` Compatibility
+
+| | Feature | Description |
+|--|---------|-------------|
+| ✅ | **50+ builtins** | `echo`, `declare`, `read`, `complete`, `trap`, `ulimit`, ... |
+| ✅ | **Full expansions** | brace, parameter, arithmetic, command/process substitution, globs, `extglob`, `globstar` |
+| ✅ | **Control flow** | `if`/`for`/`while`/`until`/`case`, `&&`/`\|\|`, subshells, pipelines, etc. |
+| ✅ | **Redirection** | here docs, here strings, fd duplication, process substitution redirects |
+| ✅ | **Arrays & variables** | indexed/associative arrays, dynamic variables, standard well-known variables, etc. |
+| ✅ | **Programmable completion** | Works with [bash-completion](https://github.com/scop/bash-completion) out of the box |
+| ✅ | **Job control** | background jobs, suspend/resume, `fg`/`bg`/`jobs` |
+| 🔷 | **Traps & options** | `DEBUG`/`ERR`/`EXIT` traps work; signal traps and options in progress |
+
+### ⌨️ User Experience
+
+| | Feature | Description |
+|--|---------|-------------|
+| ✅ | **Syntax highlighting** | Real-time as you type ([reedline](https://github.com/nushell/reedline)) |
+| ✅ | **Auto-suggestions** | History-based hints as you type ([reedline](https://github.com/nushell/reedline)) |
+| ✅ | **Rich prompts** | `PS1`/`PROMPT_COMMAND`, right prompts, [starship](https://starship.rs) compatible |
+| ✅ | **TOML config** | `~/.config/brush/config.toml` for persistent settings |
+| 🧪 | **Extras** | `fzf`/`atuin` support, zsh-style `precmd`/`preexec` hooks (experimental), VS Code terminal integration |
+
+## Installation
+
+_When you run `brush`, it should look exactly as `bash` does on your system: it processes your `.bashrc` and
+other standard configuration. If you'd like to distinguish the look of `brush` from the other shells
+on your system, you may author a `~/.brushrc` file._
+
+
+🍺 Installing using Homebrew (macOS/Linux)
+
+Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush):
+
+```bash
+brew install brush
+```
+
+
+
+
+Installing on Arch Linux
+
+Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/):
+
+```bash
+pacman -S brush
+```
+
+
+
+
+Installing on MSYS2
+
+MSYS2 users can install `brush` from the [repository](https://packages.msys2.org/base/mingw-w64-brush):
+
+```bash
+pacman -S mingw-w64-ucrt-x86_64-brush # or mingw-w64-clang-x86_64-brush or mingw-w64-clang-aarch64-brush
+```
+
+
+
+
+🚀 Installing prebuilt binaries via `cargo binstall`
+
+You may use [cargo binstall](https://github.com/cargo-bins/cargo-binstall) to install pre-built `brush` binaries. Once you've installed `cargo-binstall` you can run:
+
+```bash
+cargo binstall brush-shell
+```
+
+
+
+
+🚀 Installing prebuilt binaries from GitHub
+
+We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.:
+
+```bash
+gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*"
+```
+
+After downloading the archive for your platform, you may verify its authenticity using the [GitHub CLI](https://cli.github.com/), e.g.:
+
+```bash
+gh attestation verify brush-x86_64-unknown-linux-gnu.tar.gz --repo reubeno/brush
+```
+
+
+
+
+🐧 Installing using Nix
+
+If you are a Nix user, you can use the registered version:
+
+```bash
+nix run 'github:NixOS/nixpkgs/nixpkgs-unstable#brush' -- --version
+```
+
+
+
+
+ 🔨 Building from sources
+
+To build from sources, first install a working (and recent) `rust` toolchain; we recommend installing it via [`rustup`](https://rustup.rs/). Then run:
+
+```bash
+cargo install --locked brush-shell
+```
+
+
+
+## Community & Contributing
+
+This project started out of curiosity and a desire to learn—we're keeping that attitude. If something doesn't work the way you'd expect, [let us know](https://github.com/reubeno/brush/issues)!
+
+* [Discord server](https://discord.gg/kPRgC9j3Tj) — chat with the community
+* [Building from source](docs/how-to/build.md) — development workflow
+* [Contribution guidelines](CONTRIBUTING.md) — how to submit changes
+* [Technical docs](docs/README.md) — architecture and reference
+
+## Related Projects
+
+Other POSIX-ish shells implemented in non-C/C++ languages:
+
+* [`nushell`](https://www.nushell.sh/) — modern Rust shell (provides `reedline`)
+* [`fish`](https://fishshell.com) — user-friendly shell ([Rust port in 4.0](https://fishshell.com/blog/rustport/))
+* [`Oils`](https://github.com/oils-for-unix/oils) — bash-compatible with new Oil language
+* [`mvdan/sh`](https://github.com/mvdan/sh) — Go implementation
+* [`rusty_bash`](https://github.com/shellgei/rusty_bash) — another Rust bash-like shell
+
+
+🙏 Credits
+
+This project relies on many excellent OSS crates:
+
+* [`reedline`](https://github.com/nushell/reedline) — readline-like input and interactive features
+* [`clap`](https://github.com/clap-rs/clap) — command-line parsing
+* [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) — regex support
+* [`tokio`](https://github.com/tokio-rs/tokio) — async runtime
+* [`nix`](https://github.com/nix-rust/nix) — Unix/POSIX APIs
+* [`criterion.rs`](https://github.com/bheisler/criterion.rs) — benchmarking
+* [`bash-completion`](https://github.com/scop/bash-completion) — completion test suite
+
+
+
+---
+
+Licensed under the [MIT license](LICENSE).
diff --git a/local/recipes/shells/brush/source/benchmarks/.gitignore b/local/recipes/shells/brush/source/benchmarks/.gitignore
new file mode 100644
index 0000000000..c18dd8d83c
--- /dev/null
+++ b/local/recipes/shells/brush/source/benchmarks/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/local/recipes/shells/brush/source/benchmarks/bench.py b/local/recipes/shells/brush/source/benchmarks/bench.py
new file mode 100755
index 0000000000..3dcb6d80df
--- /dev/null
+++ b/local/recipes/shells/brush/source/benchmarks/bench.py
@@ -0,0 +1,974 @@
+#!/usr/bin/env python3
+"""
+Shell benchmarking harness for brush.
+
+Runs shell script snippets against test and reference shells using
+warmup, calibration, and timed execution phases.
+
+NOTE: This script was authored by an AI coding agent in conjunction
+ with human guidance.
+"""
+
+import argparse
+import json
+import math
+import os
+import re
+import shutil
+import statistics
+import subprocess
+import sys
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class BenchmarkCase:
+ """
+ A benchmark case measuring a specific shell operation.
+
+ The benchmark runs `loop_body` in a tight loop, with optional `setup`
+ executed once before timing and `cleanup` after.
+
+ Exit-code contract:
+ - The `setup` phase may validate preconditions and exit with non-zero
+ status to signal failure (e.g., missing external commands).
+ - The generated script always ends with `exit 0` to confirm success.
+ - Any non-zero exit from the shell is treated as a fatal benchmark error.
+ """
+
+ loop_body: str
+ setup: Optional[str] = None
+ cleanup: Optional[str] = None
+
+
+@dataclass
+class TimingResult:
+ """
+ Timing data parsed from shell's `time` builtin output.
+
+ Captures wall-clock time (real), user CPU time, and system CPU time.
+ """
+
+ real_seconds: float
+ user_seconds: float
+ sys_seconds: float
+
+
+@dataclass
+class BenchmarkResult:
+ """
+ Aggregated results from running a benchmark across multiple samples.
+
+ Contains per-iteration timing statistics (median, MAD, min, max) computed
+ from `sample_count` independent timed runs. Uses median and MAD (median
+ absolute deviation) instead of mean and stddev for robustness against
+ outliers from system jitter.
+ """
+
+ case_name: str
+ iterations_per_sample: int
+ sample_count: int
+ per_iteration_ns: float # median across samples
+ per_iteration_ns_mad: float # MAD scaled by 1.4826 for normal comparability
+ per_iteration_ns_min: float
+ per_iteration_ns_max: float
+ shell_path: str
+
+
+@dataclass
+class ComparisonResult:
+ """
+ Performance comparison between test shell and reference shell.
+
+ The `change` ratio is reference_time / test_time, so values > 1.0
+ indicate the test shell is faster than the reference.
+ """
+
+ case_name: str
+ test_result: BenchmarkResult
+ reference_result: BenchmarkResult
+ change: float # ratio: ref_time / test_time (>1 = test is faster)
+
+
+# =============================================================================
+# Built-in benchmark cases
+# =============================================================================
+
+BENCHMARK_CASES: dict[str, BenchmarkCase] = {
+ "assignment": BenchmarkCase(
+ loop_body="x=42",
+ ),
+ "colon": BenchmarkCase(
+ loop_body=":",
+ ),
+ "echo_builtin": BenchmarkCase(
+ loop_body="echo >/dev/null",
+ ),
+ "echo_cmd": BenchmarkCase(
+ setup="command -v /usr/bin/echo >/dev/null || exit 1",
+ loop_body="/usr/bin/echo >/dev/null",
+ ),
+ "increment": BenchmarkCase(
+ setup="x=0",
+ loop_body="((x++))",
+ ),
+ "subshell": BenchmarkCase(
+ loop_body="(:)",
+ ),
+ "cmdsubst": BenchmarkCase(
+ loop_body=': $(:)',
+ ),
+ "var_expand": BenchmarkCase(
+ setup='myvar="hello world"',
+ loop_body=': "$myvar"',
+ ),
+ "func_call": BenchmarkCase(
+ setup="myfunc() { :; }",
+ loop_body="myfunc",
+ ),
+ "array_access": BenchmarkCase(
+ setup='myarr=(a b c d e f g h i j)',
+ loop_body=': "${myarr[5]}"',
+ ),
+ "pattern_match": BenchmarkCase(
+ loop_body='[[ "hello world" == hello* ]]',
+ ),
+ "regex_match": BenchmarkCase(
+ loop_body='[[ "hello world" =~ ^hello.* ]]',
+ ),
+ "if_taken": BenchmarkCase(
+ loop_body="if [[ 1 -eq 1 ]]; then :; fi",
+ ),
+ "if_not_taken": BenchmarkCase(
+ loop_body="if [[ 0 -eq 1 ]]; then :; fi",
+ ),
+}
+
+
+# =============================================================================
+# Time output parsing
+# =============================================================================
+
+
+def parse_time_output(stderr: str) -> TimingResult:
+ """
+ Parse bash's default time output format.
+
+ Expected format (on stderr):
+ real 0m0.123s
+ user 0m0.045s
+ sys 0m0.012s
+
+ The generated benchmark scripts explicitly set TIMEFORMAT to match bash's
+ default format, ensuring consistent parsing across shells that support
+ this variable. Locale settings (LC_NUMERIC) could theoretically affect
+ decimal separators, but this is rare in practice.
+ """
+ # Pattern matches: real/user/sys followed by XmY.ZZZs format
+ pattern = r"(real|user|sys)\s+(\d+)m([\d.]+)s"
+ matches = re.findall(pattern, stderr)
+
+ if len(matches) < 3:
+ raise ValueError(f"Failed to parse time output: {stderr!r}")
+
+ results = {}
+ for label, minutes, seconds in matches:
+ total_seconds = int(minutes) * 60 + float(seconds)
+ results[label] = total_seconds
+
+ return TimingResult(
+ real_seconds=results.get("real", 0.0),
+ user_seconds=results.get("user", 0.0),
+ sys_seconds=results.get("sys", 0.0),
+ )
+
+
+# =============================================================================
+# Script generation
+# =============================================================================
+
+
+def generate_benchmark_script(case: BenchmarkCase, iterations: int) -> str:
+ """
+ Generate a complete benchmark script for the given case.
+
+ The script:
+ 1. Sets TIMEFORMAT to match bash's default output format
+ 2. Runs optional setup (which may exit non-zero to abort)
+ 3. Times the loop body for the specified iterations
+ 4. Runs optional cleanup
+ 5. Exits with 0 to confirm successful completion
+ """
+ lines = []
+
+ # Pin TIMEFORMAT to bash's default format for consistent parsing
+ # Format: real\t%lR\nuser\t%lU\nsys\t%lS (where %l uses Mm.SSSs format)
+ lines.append(r"TIMEFORMAT=$'real\t%lR\nuser\t%lU\nsys\t%lS'")
+
+ # Setup (optional) - may exit non-zero to signal precondition failure
+ if case.setup:
+ lines.append(case.setup)
+
+ # Timed loop (no braces needed around for loop)
+ lines.append(
+ f"time for ((i=0; i<{iterations}; i++)); do {case.loop_body}; done"
+ )
+
+ # Cleanup (optional)
+ if case.cleanup:
+ lines.append(case.cleanup)
+
+ # Explicit success exit to confirm script completed without error
+ lines.append("exit 0")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# Shell execution
+# =============================================================================
+
+
+def get_shell_flags(shell_path: str) -> list[str]:
+ """Get appropriate flags for running a shell in benchmark mode."""
+ # Check if this looks like brush
+ shell_name = shell_path.split("/")[-1]
+
+ if "brush" in shell_name:
+ return [
+ "--norc",
+ "--noprofile",
+ "--input-backend=basic",
+ "--disable-bracketed-paste",
+ "--disable-color",
+ ]
+ else:
+ # Assume bash-like shell
+ return ["--norc", "--noprofile"]
+
+
+def run_shell_script(shell_path: str, script: str) -> subprocess.CompletedProcess:
+ """Run a script using the specified shell."""
+ flags = get_shell_flags(shell_path)
+ cmd = [shell_path] + flags + ["-c", script]
+
+ return subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ )
+
+
+# =============================================================================
+# Benchmark execution
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Unit conversion constants
+# -----------------------------------------------------------------------------
+
+# Nanoseconds per second, used for timing conversions.
+NS_PER_SECOND = 1_000_000_000
+
+# -----------------------------------------------------------------------------
+# Warmup phase constants
+# -----------------------------------------------------------------------------
+
+# Default warmup duration in seconds. Warmup ensures CPU caches are hot and
+# the shell's internal state is stable before measurement begins.
+# Set to 0 to skip warmup entirely.
+DEFAULT_WARMUP_DURATION_SECONDS = 0.5
+
+# Number of iterations for the quick probe used to estimate per-iteration time
+# during warmup. This should be small enough to complete quickly but large
+# enough to get a rough timing estimate.
+WARMUP_PROBE_ITERATIONS = 100
+
+# Multiplier applied to WARMUP_PROBE_ITERATIONS when the probe completes too
+# fast to measure (real_seconds == 0). This is a fallback for extremely fast
+# operations where even 100 iterations complete in sub-millisecond time.
+WARMUP_FALLBACK_MULTIPLIER = 10
+
+# -----------------------------------------------------------------------------
+# Calibration phase constants
+# -----------------------------------------------------------------------------
+
+# Starting iteration count for calibration. We double this until we get a
+# measurable time (>= CALIBRATION_MIN_TIME_SECONDS).
+CALIBRATION_MIN_ITERATIONS = 100
+
+# Minimum elapsed time in seconds for reliable calibration measurement.
+# Below this threshold, timer resolution and process startup overhead
+# dominate the measurement.
+CALIBRATION_MIN_TIME_SECONDS = 0.1
+
+# Maximum iterations to attempt during calibration. Prevents runaway loops
+# if something is fundamentally broken.
+CALIBRATION_MAX_ITERATIONS = 100_000_000
+
+# Maximum iterations per sample. Prevents extremely long-running samples
+# even if per-iteration time is very small.
+MAX_TARGET_ITERATIONS = 1_000_000_000
+
+# -----------------------------------------------------------------------------
+# Statistical sampling constants
+# -----------------------------------------------------------------------------
+
+# Default number of independent samples to collect. More samples improve
+# statistical reliability but increase total benchmark time.
+DEFAULT_SAMPLES = 10
+
+# Minimum duration per sample in seconds. Samples shorter than this are
+# unreliable due to timer resolution and system noise.
+MIN_SAMPLE_DURATION_SECONDS = 0.5
+
+# Scale factor to convert MAD (median absolute deviation) to an estimate
+# comparable to standard deviation for normally distributed data.
+# For a normal distribution, MAD * 1.4826 ≈ stddev.
+MAD_NORMAL_SCALE_FACTOR = 1.4826
+
+# Coefficient of variation threshold (as percentage) above which we flag
+# results as having high variance, indicating unreliable measurements.
+HIGH_VARIANCE_THRESHOLD_PCT = 10.0
+
+# -----------------------------------------------------------------------------
+# Performance comparison constants
+# -----------------------------------------------------------------------------
+
+# Default benchmark duration in seconds (total time budget per benchmark).
+DEFAULT_DURATION_SECONDS = 10.0
+
+# Threshold for classifying performance change as "faster" (test outperforms
+# reference). A value of 1.1 means test must be at least 10% faster.
+CHANGE_FASTER_THRESHOLD = 1.1
+
+# Threshold for classifying performance change as "similar" (no significant
+# difference). A value of 0.95 means test can be up to 5% slower and still
+# be considered similar. Below this is classified as "slower".
+CHANGE_SIMILAR_THRESHOLD = 0.95
+
+
+def run_benchmark_phase(
+ shell_path: str, case: BenchmarkCase, iterations: int
+) -> TimingResult:
+ """
+ Run a single benchmark phase and return timing.
+
+ Raises RuntimeError if the shell exits with non-zero status, which
+ indicates either a setup precondition failure or an execution error.
+ """
+ script = generate_benchmark_script(case, iterations)
+ result = run_shell_script(shell_path, script)
+
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"Benchmark failed (exit {result.returncode}). This may indicate "
+ f"a setup precondition failure or shell execution error.\n"
+ f"stdout: {result.stdout}\n"
+ f"stderr: {result.stderr}"
+ )
+
+ return parse_time_output(result.stderr)
+
+
+def run_benchmark(
+ shell_path: str,
+ case_name: str,
+ case: BenchmarkCase,
+ target_duration: float,
+ num_samples: int = DEFAULT_SAMPLES,
+ warmup_duration: float = DEFAULT_WARMUP_DURATION_SECONDS,
+ verbose: bool = False,
+) -> BenchmarkResult:
+ """
+ Run a complete benchmark with warmup, calibration, and multiple sample phases.
+
+ Args:
+ shell_path: Path to shell executable
+ case_name: Name of the benchmark case
+ case: Benchmark case to run
+ target_duration: Total time budget in seconds (divided among samples)
+ num_samples: Number of independent samples to collect
+ warmup_duration: Time in seconds to spend warming up (default: 0.5s; 0 to skip)
+ verbose: Print progress information
+
+ Returns:
+ BenchmarkResult with timing statistics (median, MAD) across all samples
+ """
+ # Determine actual sample count based on duration constraints
+ sample_duration = target_duration / num_samples
+ actual_samples = num_samples
+
+ if sample_duration < MIN_SAMPLE_DURATION_SECONDS:
+ actual_samples = max(1, int(target_duration / MIN_SAMPLE_DURATION_SECONDS))
+ sample_duration = target_duration / actual_samples
+ # Always warn when we reduce samples (even in non-verbose mode)
+ print(
+ f" ⚠️ Duration too short for {num_samples} samples; "
+ f"using {actual_samples} sample(s) of {sample_duration:.1f}s instead",
+ file=sys.stderr,
+ )
+
+ # Phase 1: Time-based warmup (skip if warmup_duration is 0)
+ if warmup_duration > 0:
+ if verbose:
+ print(f" 🔥 Warming up ({warmup_duration}s)...", file=sys.stderr)
+
+ warmup_probe = run_benchmark_phase(shell_path, case, WARMUP_PROBE_ITERATIONS)
+ warmup_per_iter = warmup_probe.real_seconds / WARMUP_PROBE_ITERATIONS
+ if warmup_per_iter > 0:
+ warmup_iterations = max(1, int(warmup_duration / warmup_per_iter))
+ else:
+ # Probe was too fast to measure - use fallback iteration count
+ warmup_iterations = WARMUP_PROBE_ITERATIONS * WARMUP_FALLBACK_MULTIPLIER
+ if verbose:
+ print(
+ f" (warmup probe too fast to measure, using {warmup_iterations} iterations)",
+ file=sys.stderr,
+ )
+
+ run_benchmark_phase(shell_path, case, warmup_iterations)
+ elif verbose:
+ print(" 🔥 Skipping warmup (duration=0)", file=sys.stderr)
+
+ if verbose:
+ print(" 📏 Calibrating...", file=sys.stderr)
+
+ # Phase 2: Adaptive calibration
+ # Keep doubling iterations until we get a measurable time
+ calibration_iterations = CALIBRATION_MIN_ITERATIONS
+ calibration_timing: Optional[TimingResult] = None
+ while calibration_iterations <= CALIBRATION_MAX_ITERATIONS:
+ calibration_timing = run_benchmark_phase(shell_path, case, calibration_iterations)
+
+ if calibration_timing.real_seconds >= CALIBRATION_MIN_TIME_SECONDS:
+ # We have enough time for reliable measurement
+ break
+
+ if verbose:
+ print(
+ f" (calibration too fast: {calibration_timing.real_seconds:.4f}s "
+ f"for {calibration_iterations} iters, doubling...)",
+ file=sys.stderr,
+ )
+
+ calibration_iterations *= 2
+
+ assert calibration_timing is not None # Loop always runs at least once
+ if calibration_timing.real_seconds < CALIBRATION_MIN_TIME_SECONDS:
+ # Even at max iterations, still too fast - use what we have
+ if verbose:
+ print(
+ f" (warning: calibration capped at {calibration_iterations} iterations)",
+ file=sys.stderr,
+ )
+
+ # Calculate iterations needed for each sample
+ per_iteration_seconds = calibration_timing.real_seconds / calibration_iterations
+ if per_iteration_seconds <= 0:
+ # Fallback if calibration was too fast: use minimum measurable time
+ # divided by max iterations as a conservative lower bound
+ per_iteration_seconds = CALIBRATION_MIN_TIME_SECONDS / CALIBRATION_MAX_ITERATIONS
+
+ iterations_per_sample = max(1, int(sample_duration / per_iteration_seconds))
+ iterations_per_sample = min(iterations_per_sample, MAX_TARGET_ITERATIONS)
+
+ if verbose:
+ print(
+ f" ⏱️ Running {actual_samples} samples of {iterations_per_sample} iterations "
+ f"(est. {sample_duration:.1f}s each)...",
+ file=sys.stderr,
+ )
+
+ # Phase 3: Collect multiple samples
+ sample_ns_values: list[float] = []
+ for sample_idx in range(actual_samples):
+ sample_timing = run_benchmark_phase(shell_path, case, iterations_per_sample)
+ per_iter_ns = (sample_timing.real_seconds / iterations_per_sample) * NS_PER_SECOND
+ sample_ns_values.append(per_iter_ns)
+
+ if verbose:
+ print(
+ f" Sample {sample_idx + 1}/{actual_samples}: "
+ f"{format_duration_ns(per_iter_ns)}/iter",
+ file=sys.stderr,
+ )
+
+ # Compute statistics using median and MAD for robustness against outliers
+ median_ns = statistics.median(sample_ns_values)
+ if len(sample_ns_values) > 1:
+ # MAD = median(|x_i - median(x)|), scaled to be comparable to stddev
+ deviations = [abs(x - median_ns) for x in sample_ns_values]
+ mad_ns = statistics.median(deviations) * MAD_NORMAL_SCALE_FACTOR
+ else:
+ mad_ns = 0.0
+ min_ns = min(sample_ns_values)
+ max_ns = max(sample_ns_values)
+
+ return BenchmarkResult(
+ case_name=case_name,
+ iterations_per_sample=iterations_per_sample,
+ sample_count=actual_samples,
+ per_iteration_ns=median_ns,
+ per_iteration_ns_mad=mad_ns,
+ per_iteration_ns_min=min_ns,
+ per_iteration_ns_max=max_ns,
+ shell_path=shell_path,
+ )
+
+
+# =============================================================================
+# Output formatting
+# =============================================================================
+
+
+def format_duration_ns(ns: float) -> str:
+ """Format a duration in nanoseconds to a human-readable string."""
+ if ns >= NS_PER_SECOND:
+ return f"{ns / NS_PER_SECOND:.3f} s"
+ elif ns >= 1e6:
+ return f"{ns / 1e6:.3f} ms"
+ elif ns >= 1e3:
+ return f"{ns / 1e3:.3f} µs"
+ else:
+ return f"{ns:.3f} ns"
+
+
+def format_result_with_variance(result: BenchmarkResult) -> str:
+ """
+ Format a benchmark result with variance indicator.
+
+ Uses MAD-based coefficient of variation for robustness against outliers.
+ """
+ median_str = format_duration_ns(result.per_iteration_ns)
+
+ if result.sample_count <= 1 or result.per_iteration_ns == 0:
+ return f"{median_str}/iter"
+
+ # Coefficient of variation using MAD (scaled to be stddev-comparable)
+ cv_pct = (result.per_iteration_ns_mad / result.per_iteration_ns) * 100
+
+ # Flag high variance with warning indicator
+ if cv_pct > HIGH_VARIANCE_THRESHOLD_PCT:
+ return f"{median_str}/iter (±{cv_pct:.1f}% ⚠️)"
+ else:
+ return f"{median_str}/iter (±{cv_pct:.1f}%)"
+
+
+def format_change(change: float) -> str:
+ """
+ Format performance change ratio with emoji indicator and percentage.
+
+ Args:
+ change: Ratio of reference_time / test_time. Values > 1.0 mean
+ the test shell is faster than the reference.
+
+ Returns:
+ Formatted string with emoji and percentage change.
+ """
+ # Calculate percentage change (negative = faster, positive = slower)
+ # change > 1 means test is faster, so pct_change should be negative
+ pct_change = (1.0 / change - 1.0) * 100
+
+ if change >= CHANGE_FASTER_THRESHOLD:
+ emoji = "🚀"
+ text = f"{pct_change:.1f}% (faster)"
+ elif change >= CHANGE_SIMILAR_THRESHOLD:
+ emoji = "⚖️ "
+ text = f"{pct_change:+.1f}% (similar)"
+ else:
+ emoji = "🐢"
+ text = f"+{pct_change:.1f}% (slower)"
+ return f"{emoji} {text}"
+
+
+def print_human_single_shell_results(results: list[BenchmarkResult], shell: str):
+ """Print results for a single shell (no comparison)."""
+ print()
+ print("=" * 70)
+ print("📊 Shell Benchmark Results")
+ print("=" * 70)
+ print(f" Shell: {shell}")
+ print("=" * 70)
+ print()
+
+ for result in results:
+ print(f"🧪 {result.case_name}")
+ print(f" {format_result_with_variance(result)}")
+ print(f" ({result.sample_count} samples, {result.iterations_per_sample} iters/sample)")
+ print()
+
+
+def compute_geometric_mean_change(comparisons: list[ComparisonResult]) -> float:
+ """
+ Compute the geometric mean of change ratios across comparisons.
+
+ Geometric mean is the correct way to average ratios/rates. Returns 1.0
+ if the list is empty or if any change ratio is non-positive (which would
+ indicate a measurement error).
+ """
+ if not comparisons:
+ return 1.0
+
+ # Filter out any non-positive values (shouldn't happen, but guard against it)
+ valid_changes = [comp.change for comp in comparisons if comp.change > 0]
+ if not valid_changes:
+ return 1.0
+
+ log_sum = sum(math.log(change) for change in valid_changes)
+ return math.exp(log_sum / len(valid_changes))
+
+
+def print_human_results(
+ comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
+):
+ """Print comparison results in human-readable format with emoji."""
+ print()
+ print("=" * 70)
+ print("📊 Shell Benchmark Results")
+ print("=" * 70)
+ print(f" Test shell: {test_shell}")
+ print(f" Reference shell: {ref_shell}")
+ print("=" * 70)
+ print()
+
+ for comp in comparisons:
+ print(f"🧪 {comp.case_name}")
+ print(f" Test: {format_result_with_variance(comp.test_result)}")
+ print(f" Reference: {format_result_with_variance(comp.reference_result)}")
+ print(f" Change: {format_change(comp.change)}")
+ print()
+
+ # Summary - use geometric mean for averaging ratios (more statistically sound)
+ print("-" * 70)
+ avg_change = compute_geometric_mean_change(comparisons)
+ print(f"📈 Overall change (geometric mean): {format_change(avg_change)}")
+ print()
+
+
+def _result_to_json(result: BenchmarkResult) -> dict:
+ """
+ Convert a BenchmarkResult to a JSON-serializable dict.
+
+ Note: per_iteration_ns is the median, and per_iteration_ns_mad is the
+ MAD (median absolute deviation) scaled by 1.4826 for normal comparability.
+ """
+ return {
+ "iterations_per_sample": result.iterations_per_sample,
+ "sample_count": result.sample_count,
+ "per_iteration_ns": result.per_iteration_ns,
+ "per_iteration_ns_mad": result.per_iteration_ns_mad,
+ "per_iteration_ns_min": result.per_iteration_ns_min,
+ "per_iteration_ns_max": result.per_iteration_ns_max,
+ }
+
+
+def print_json_single_shell_results(results: list[BenchmarkResult], shell: str):
+ """Print results for a single shell in JSON format."""
+ output = {
+ "shell": shell,
+ "results": [
+ {
+ "name": result.case_name,
+ **_result_to_json(result),
+ }
+ for result in results
+ ],
+ }
+ print(json.dumps(output, indent=2))
+
+
+def print_json_results(
+ comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
+):
+ """Print comparison results in JSON format."""
+ avg_change = compute_geometric_mean_change(comparisons)
+ output = {
+ "test_shell": test_shell,
+ "reference_shell": ref_shell,
+ "results": [
+ {
+ "name": comp.case_name,
+ "test": _result_to_json(comp.test_result),
+ "reference": _result_to_json(comp.reference_result),
+ "change": comp.change,
+ }
+ for comp in comparisons
+ ],
+ "overall_change": avg_change,
+ }
+ print(json.dumps(output, indent=2))
+
+
+# =============================================================================
+# Main entry point
+# =============================================================================
+
+
+def resolve_shell_path(shell: str) -> str:
+ """
+ Resolve shell path, checking it exists and is executable.
+
+ Args:
+ shell: Shell name or path (e.g., 'bash' or '/usr/bin/bash')
+
+ Returns:
+ Resolved absolute path to the shell executable.
+
+ Exits with error if shell is not found or not executable.
+ """
+ # If it's an absolute or relative path, use as-is
+ if "/" in shell:
+ resolved = shell
+ else:
+ # Search PATH
+ resolved = shutil.which(shell)
+ if resolved is None:
+ print(f"Error: Shell '{shell}' not found in PATH", file=sys.stderr)
+ sys.exit(1)
+
+ # Verify it exists and is executable
+ if not os.path.isfile(resolved):
+ print(f"Error: Shell path '{resolved}' does not exist or is not a file", file=sys.stderr)
+ sys.exit(1)
+ if not os.access(resolved, os.X_OK):
+ print(f"Error: Shell '{resolved}' is not executable", file=sys.stderr)
+ sys.exit(1)
+
+ return resolved
+
+
+def _positive_float(value: str) -> float:
+ """Argparse type validator for positive float values."""
+ try:
+ fval = float(value)
+ except ValueError:
+ raise argparse.ArgumentTypeError(f"invalid float value: '{value}'")
+ if fval < 0:
+ raise argparse.ArgumentTypeError(f"value must be non-negative: {fval}")
+ return fval
+
+
+def _positive_int(value: str) -> int:
+ """Argparse type validator for positive integer values."""
+ try:
+ ival = int(value)
+ except ValueError:
+ raise argparse.ArgumentTypeError(f"invalid integer value: '{value}'")
+ if ival <= 0:
+ raise argparse.ArgumentTypeError(f"value must be positive: {ival}")
+ return ival
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Shell benchmarking harness for brush",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Available benchmarks:
+ assignment - Variable assignment (x=42)
+ colon - No-op builtin (:)
+ echo_builtin - Echo builtin to /dev/null
+ echo_cmd - External /usr/bin/echo to /dev/null
+ increment - Arithmetic increment ((x++))
+ subshell - Subshell fork ((:))
+ cmdsubst - Command substitution ($(...))
+ var_expand - Variable expansion ("$var")
+ func_call - Function invocation
+ array_access - Array element access (${arr[i]})
+ pattern_match - Glob pattern match in [[ ]]
+ regex_match - Regex match in [[ =~ ]]
+ if_taken - If conditional (branch taken)
+ if_not_taken - If conditional (branch not taken)
+
+Examples:
+ %(prog)s --shell ./target/release/brush
+ %(prog)s --shell brush --reference-shell bash --benchmarks colon increment
+ %(prog)s --shell brush --duration 5 --json
+""",
+ )
+
+ parser.add_argument(
+ "-s",
+ "--shell",
+ dest="shell",
+ type=str,
+ required=True,
+ help="Path to test shell binary",
+ )
+
+ parser.add_argument(
+ "-r",
+ "--reference-shell",
+ dest="reference_shell",
+ type=str,
+ default=None,
+ help="Path to reference shell for comparison (optional; if omitted, only test shell is benchmarked)",
+ )
+
+ parser.add_argument(
+ "-b",
+ "--benchmarks",
+ dest="benchmarks",
+ type=str,
+ nargs="+",
+ default=list(BENCHMARK_CASES.keys()),
+ choices=list(BENCHMARK_CASES.keys()),
+ help="Name(s) of benchmark(s) to run (default: all)",
+ )
+
+ parser.add_argument(
+ "--samples",
+ dest="samples",
+ type=_positive_int,
+ default=DEFAULT_SAMPLES,
+ help=f"Number of samples to collect per benchmark (default: {DEFAULT_SAMPLES})",
+ )
+
+ parser.add_argument(
+ "-d",
+ "--duration",
+ dest="duration",
+ type=_positive_float,
+ default=DEFAULT_DURATION_SECONDS,
+ help=f"Number of seconds to run each test (default: {DEFAULT_DURATION_SECONDS})",
+ )
+
+ parser.add_argument(
+ "-w",
+ "--warmup-duration",
+ dest="warmup_duration",
+ type=_positive_float,
+ default=DEFAULT_WARMUP_DURATION_SECONDS,
+ help=f"Warmup duration in seconds; 0 to skip (default: {DEFAULT_WARMUP_DURATION_SECONDS})",
+ )
+
+ parser.add_argument(
+ "-j",
+ "--json",
+ dest="json_output",
+ action="store_true",
+ help="Output results in JSON format",
+ )
+
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ dest="verbose",
+ action="store_true",
+ help="Print progress information",
+ )
+
+ args = parser.parse_args()
+
+ # Resolve shell paths
+ test_shell = resolve_shell_path(args.shell)
+ ref_shell = resolve_shell_path(args.reference_shell) if args.reference_shell else None
+
+ if not args.json_output:
+ print("🐚 Shell Benchmark Harness", file=sys.stderr)
+ print(f" Test shell: {test_shell}", file=sys.stderr)
+ if ref_shell:
+ print(f" Reference shell: {ref_shell}", file=sys.stderr)
+ print(f" Duration: {args.duration}s per benchmark", file=sys.stderr)
+ print(f" Warmup: {args.warmup_duration}s", file=sys.stderr)
+ print(f" Samples: {args.samples}", file=sys.stderr)
+ print(f" Benchmarks: {', '.join(args.benchmarks)}", file=sys.stderr)
+ print(file=sys.stderr)
+
+ # Single-shell mode (no reference)
+ if ref_shell is None:
+ results = []
+ for bench_name in args.benchmarks:
+ case = BENCHMARK_CASES[bench_name]
+
+ if not args.json_output:
+ print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
+ print(f" Testing: {test_shell}", file=sys.stderr)
+
+ result = run_benchmark(
+ test_shell,
+ bench_name,
+ case,
+ args.duration,
+ args.samples,
+ args.warmup_duration,
+ verbose=args.verbose,
+ )
+ results.append(result)
+
+ if not args.json_output:
+ print(file=sys.stderr)
+
+ if args.json_output:
+ print_json_single_shell_results(results, test_shell)
+ else:
+ print_human_single_shell_results(results, test_shell)
+ return
+
+ # Comparison mode (test vs reference)
+ comparisons = []
+
+ for bench_name in args.benchmarks:
+ case = BENCHMARK_CASES[bench_name]
+
+ if not args.json_output:
+ print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
+
+ # Run on test shell
+ if not args.json_output:
+ print(f" Testing: {test_shell}", file=sys.stderr)
+ test_result = run_benchmark(
+ test_shell,
+ bench_name,
+ case,
+ args.duration,
+ args.samples,
+ args.warmup_duration,
+ verbose=args.verbose,
+ )
+
+ # Run on reference shell
+ if not args.json_output:
+ print(f" Testing: {ref_shell}", file=sys.stderr)
+ ref_result = run_benchmark(
+ ref_shell,
+ bench_name,
+ case,
+ args.duration,
+ args.samples,
+ args.warmup_duration,
+ verbose=args.verbose,
+ )
+
+ # Calculate performance change (reference time / test time)
+ # > 1 means test shell is faster
+ # Note: per_iteration_ns is the median value
+ if test_result.per_iteration_ns > 0:
+ change = ref_result.per_iteration_ns / test_result.per_iteration_ns
+ else:
+ # Test result was unmeasurably fast; treat as equivalent
+ change = 1.0
+
+ comparisons.append(
+ ComparisonResult(
+ case_name=bench_name,
+ test_result=test_result,
+ reference_result=ref_result,
+ change=change,
+ )
+ )
+
+ if not args.json_output:
+ print(file=sys.stderr)
+
+ # Output results
+ if args.json_output:
+ print_json_results(comparisons, test_shell, ref_shell)
+ else:
+ print_human_results(comparisons, test_shell, ref_shell)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/local/recipes/shells/brush/source/brush-builtins/Cargo.toml b/local/recipes/shells/brush/source/brush-builtins/Cargo.toml
new file mode 100644
index 0000000000..f8a4846766
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/Cargo.toml
@@ -0,0 +1,173 @@
+[package]
+name = "brush-builtins"
+description = "Builtins for a POSIX/bash shell (used by brush-shell)"
+version = "0.2.0"
+categories.workspace = true
+edition.workspace = true
+keywords.workspace = true
+license.workspace = true
+readme.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lib]
+bench = false
+
+[features]
+default = [
+ 'builtin.alias',
+ 'builtin.bg',
+ 'builtin.bind',
+ 'builtin.break',
+ 'builtin.builtin',
+ 'builtin.caller',
+ 'builtin.cd',
+ 'builtin.colon',
+ 'builtin.command',
+ 'builtin.compgen',
+ 'builtin.compopt',
+ 'builtin.complete',
+ 'builtin.continue',
+ 'builtin.declare',
+ 'builtin.dirs',
+ 'builtin.dot',
+ 'builtin.echo',
+ 'builtin.enable',
+ 'builtin.eval',
+ 'builtin.exec',
+ 'builtin.exit',
+ 'builtin.export',
+ 'builtin.false',
+ 'builtin.fc',
+ 'builtin.fg',
+ 'builtin.getopts',
+ 'builtin.hash',
+ 'builtin.help',
+ 'builtin.history',
+ 'builtin.jobs',
+ 'builtin.kill',
+ 'builtin.let',
+ 'builtin.mapfile',
+ 'builtin.popd',
+ 'builtin.printf',
+ 'builtin.pushd',
+ 'builtin.pwd',
+ 'builtin.read',
+ 'builtin.return',
+ 'builtin.set',
+ 'builtin.shift',
+ 'builtin.shopt',
+ 'builtin.suspend',
+ 'builtin.test',
+ 'builtin.times',
+ 'builtin.trap',
+ 'builtin.true',
+ 'builtin.type',
+ 'builtin.ulimit',
+ 'builtin.umask',
+ 'builtin.unalias',
+ 'builtin.unset',
+ 'builtin.wait',
+]
+
+'builtin.alias' = []
+'builtin.bg' = []
+'builtin.bind' = []
+'builtin.break' = []
+'builtin.builtin' = []
+'builtin.caller' = []
+'builtin.cd' = []
+'builtin.colon' = []
+'builtin.command' = []
+'builtin.compgen' = []
+'builtin.complete' = []
+'builtin.compopt' = []
+'builtin.continue' = []
+'builtin.declare' = []
+'builtin.dirs' = []
+'builtin.dot' = []
+'builtin.echo' = []
+'builtin.enable' = []
+'builtin.eval' = []
+'builtin.exec' = []
+'builtin.exit' = []
+'builtin.export' = []
+'builtin.false' = []
+'builtin.fc' = []
+'builtin.fg' = []
+'builtin.getopts' = []
+'builtin.hash' = []
+'builtin.help' = []
+'builtin.history' = []
+'builtin.jobs' = []
+'builtin.kill' = []
+'builtin.let' = []
+'builtin.mapfile' = []
+'builtin.popd' = []
+'builtin.printf' = []
+'builtin.pushd' = []
+'builtin.pwd' = []
+'builtin.read' = []
+'builtin.return' = []
+'builtin.set' = []
+'builtin.shift' = []
+'builtin.shopt' = []
+'builtin.suspend' = []
+'builtin.test' = []
+'builtin.times' = []
+'builtin.trap' = []
+'builtin.true' = []
+'builtin.type' = []
+'builtin.ulimit' = []
+'builtin.umask' = []
+'builtin.unalias' = []
+'builtin.unset' = []
+'builtin.wait' = []
+
+[lints]
+workspace = true
+
+[dependencies]
+brush-core = { version = "^0.5.0", path = "../brush-core" }
+brush-parser = { version = "^0.4.0", path = "../brush-parser" }
+cfg-if = "1.0.4"
+chrono = "0.4.44"
+clap = { version = "4.6.0", features = ["derive", "wrap_help"] }
+fancy-regex = "0.18.0"
+itertools = "0.14.0"
+strum = "0.28.0"
+thiserror = "2.0.18"
+tracing = "0.1.44"
+
+[target.'cfg(target_family = "wasm")'.dependencies]
+tokio = { version = "1.52.3", features = ["io-util", "macros", "rt"] }
+
+[target.'cfg(any(unix, windows))'.dependencies]
+tokio = { version = "1.52.3", features = [
+ "io-util",
+ "macros",
+ "process",
+ "rt",
+ "rt-multi-thread",
+ "signal",
+ "sync",
+] }
+uucore = { version = "0.8.0", default-features = false, features = ["format"] }
+
+[target.'cfg(unix)'.dependencies]
+nix = { version = "0.31.2", features = [
+ "fs",
+ "process",
+ "resource",
+ "signal",
+ "term",
+ "user",
+] }
+rlimit = "0.11.0"
+
+[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
+procfs = "0.18.0"
+
+[dev-dependencies]
+anyhow = "1.0.102"
+pretty_assertions = { version = "1.4.1", features = ["unstable"] }
diff --git a/local/recipes/shells/brush/source/brush-builtins/LICENSE b/local/recipes/shells/brush/source/brush-builtins/LICENSE
new file mode 120000
index 0000000000..ea5b60640b
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/LICENSE
@@ -0,0 +1 @@
+../LICENSE
\ No newline at end of file
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/alias.rs b/local/recipes/shells/brush/source/brush-builtins/src/alias.rs
new file mode 100644
index 0000000000..8ba642ffd9
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/alias.rs
@@ -0,0 +1,55 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Manage aliases within the shell.
+#[derive(Parser)]
+pub(crate) struct AliasCommand {
+ /// Print all defined aliases in a reusable format.
+ #[arg(short = 'p')]
+ print: bool,
+
+ /// List of aliases to display or update.
+ #[arg(name = "name[=value]")]
+ aliases: Vec,
+}
+
+impl builtins::Command for AliasCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut exit_code = ExecutionResult::success();
+
+ if self.print || self.aliases.is_empty() {
+ for (name, value) in context.shell.aliases() {
+ writeln!(context.stdout(), "alias {name}='{value}'")?;
+ }
+ } else {
+ for alias in &self.aliases {
+ if let Some((name, unexpanded_value)) = alias.split_once('=')
+ && !name.is_empty()
+ {
+ context
+ .shell
+ .aliases_mut()
+ .insert(name.to_owned(), unexpanded_value.to_owned());
+ } else if let Some(value) = context.shell.aliases().get(alias) {
+ writeln!(context.stdout(), "alias {alias}='{value}'")?;
+ } else {
+ writeln!(
+ context.stderr(),
+ "{}: {alias}: not found",
+ context.command_name
+ )?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(exit_code)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/bg.rs b/local/recipes/shells/brush/source/brush-builtins/src/bg.rs
new file mode 100644
index 0000000000..97d8342238
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/bg.rs
@@ -0,0 +1,47 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Moves a job to run in the background.
+#[derive(Parser)]
+pub(crate) struct BgCommand {
+ /// List of job specs to move to background.
+ job_specs: Vec,
+}
+
+impl builtins::Command for BgCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut exit_code = ExecutionResult::success();
+
+ if !self.job_specs.is_empty() {
+ for job_spec in &self.job_specs {
+ if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
+ job.move_to_background()?;
+ } else {
+ writeln!(
+ context.stderr(),
+ "{}: {}: no such job",
+ context.command_name,
+ job_spec
+ )?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+ } else {
+ if let Some(job) = context.shell.jobs_mut().current_job_mut() {
+ job.move_to_background()?;
+ } else {
+ writeln!(context.stderr(), "{}: no current job", context.command_name)?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+
+ Ok(exit_code)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/bind.rs b/local/recipes/shells/brush/source/brush-builtins/src/bind.rs
new file mode 100644
index 0000000000..de41770c91
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/bind.rs
@@ -0,0 +1,572 @@
+use clap::{Parser, ValueEnum};
+use itertools::Itertools as _;
+use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc};
+use strum::IntoEnumIterator;
+use tokio::sync::Mutex;
+
+use brush_core::{
+ ExecutionExitCode, ExecutionResult, builtins,
+ interfaces::{self, InputFunction, KeyAction, KeySequence},
+ sys, trace_categories,
+};
+
+/// Identifier for a keymap
+#[derive(Clone, ValueEnum)]
+enum BindKeyMap {
+ #[clap(name = "emacs-standard", alias = "emacs")]
+ EmacsStandard,
+ #[clap(name = "emacs-meta")]
+ EmacsMeta,
+ #[clap(name = "emacs-ctlx")]
+ EmacsCtlx,
+ #[clap(name = "vi-command", aliases = &["vi", "vi-move"])]
+ ViCommand,
+ #[clap(name = "vi-insert")]
+ ViInsert,
+}
+
+impl BindKeyMap {
+ const fn is_vi(&self) -> bool {
+ matches!(self, Self::ViCommand | Self::ViInsert)
+ }
+
+ #[expect(dead_code)]
+ const fn is_emacs(&self) -> bool {
+ matches!(
+ self,
+ Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx
+ )
+ }
+}
+
+/// Inspect and modify key bindings and other input configuration.
+#[derive(Parser)]
+pub(crate) struct BindCommand {
+ /// Name of key map to use.
+ #[arg(short = 'm')]
+ keymap: Option,
+ /// List functions.
+ #[arg(short = 'l')]
+ list_funcs: bool,
+ /// List functions and bindings.
+ #[arg(short = 'P')]
+ list_funcs_and_bindings: bool,
+ /// List functions and bindings in a format suitable for use as input.
+ #[arg(short = 'p')]
+ list_funcs_and_bindings_reusable: bool,
+ /// List key sequences that invoke macros.
+ #[arg(short = 'S')]
+ list_key_seqs_that_invoke_macros: bool,
+ /// List key sequences that invoke macros in a format suitable for use as input.
+ #[arg(short = 's')]
+ list_key_seqs_that_invoke_macros_reusable: bool,
+ /// List variables.
+ #[arg(short = 'V')]
+ list_vars: bool,
+ /// List variables in a format suitable for use as input.
+ #[arg(short = 'v')]
+ list_vars_reusable: bool,
+ /// Find the keys bound to the given named function.
+ #[arg(short = 'q', value_name = "FUNC_NAME")]
+ query_func_bindings: Option,
+ /// Remove all bindings for the given named function.
+ #[arg(short = 'u', value_name = "FUNC_NAME")]
+ remove_func_bindings: Option,
+ /// Remove the binding for the given key sequence.
+ #[arg(short = 'r', value_name = "KEY_SEQ")]
+ remove_key_seq_binding: Option,
+ /// Import bindings from the given file.
+ #[arg(short = 'f', value_name = "PATH")]
+ bindings_file: Option,
+ /// Bind key sequence to command.
+ #[arg(short = 'x', value_name = "BINDING")]
+ key_seq_bindings: Vec,
+ /// List key sequence bindings.
+ #[arg(short = 'X')]
+ list_key_seq_bindings: bool,
+ /// Key sequence binding to readline function or command.
+ key_sequence: Option,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub(crate) enum BindError {
+ /// Unknown function specified.
+ #[error("unknown function: {0}")]
+ UnknownFunction(String),
+
+ /// Unknown key binding function.
+ #[error("unknown key binding function: {0}")]
+ UnknownKeyBindingFunction(String),
+
+ /// Unimplemented functionality.
+ #[error("unimplemented: {0}")]
+ Unimplemented(&'static str),
+
+ /// An I/O error occurred.
+ #[error("I/O error occurred")]
+ IoError(#[from] std::io::Error),
+
+ /// A binding parse error occurred.
+ #[error(transparent)]
+ BindingParseError(#[from] brush_parser::BindingParseError),
+}
+
+impl brush_core::BuiltinError for BindError {}
+
+impl From<&BindError> for brush_core::ExecutionExitCode {
+ fn from(_err: &BindError) -> Self {
+ Self::GeneralError
+ }
+}
+
+impl builtins::Command for BindCommand {
+ type Error = BindError;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if let Some(key_bindings) = context.shell.key_bindings() {
+ Ok(self.execute_impl(key_bindings, &context).await?)
+ } else {
+ tracing::debug!(target: trace_categories::INPUT,
+ "bind: key bindings not supported in this config");
+
+ // Silently succeed when key bindings are unavailable (e.g., in
+ // non-interactive mode or with an input backend that doesn't
+ // yet support them).
+ Ok(ExecutionExitCode::Success.into())
+ }
+ }
+}
+
+impl BindCommand {
+ #[allow(clippy::too_many_lines)]
+ async fn execute_impl(
+ &self,
+ bindings: &Arc>,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result {
+ let mut bindings = bindings.lock().await;
+
+ if self.list_funcs {
+ for func in interfaces::InputFunction::iter() {
+ writeln!(context.stdout(), "{func}")?;
+ }
+ }
+
+ if self.list_funcs_and_bindings {
+ display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?;
+ }
+
+ if self.list_funcs_and_bindings_reusable {
+ display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?;
+ }
+
+ if self.list_key_seqs_that_invoke_macros {
+ display_macros(&*bindings, context, false /* reusable? */)?;
+ }
+
+ if self.list_key_seqs_that_invoke_macros_reusable {
+ display_macros(&*bindings, context, true /* reusable? */)?;
+ }
+
+ if self.list_vars {
+ let options = &context.shell.completion_config().fallback_options;
+
+ // For now we'll just display a few items and show defaults.
+ writeln!(
+ context.stdout(),
+ "mark-directories is set to `{}'",
+ to_onoff(options.mark_directories)
+ )?;
+ writeln!(
+ context.stdout(),
+ "mark-symlinked-directories is set to `{}'",
+ to_onoff(options.mark_symlinked_directories)
+ )?;
+ }
+
+ if self.list_vars_reusable {
+ let options = &context.shell.completion_config().fallback_options;
+
+ // For now we'll just display a few items and show defaults.
+ writeln!(
+ context.stdout(),
+ "set mark-directories {}",
+ to_onoff(options.mark_directories)
+ )?;
+ writeln!(
+ context.stdout(),
+ "set mark-symlinked-directories {}",
+ to_onoff(options.mark_symlinked_directories)
+ )?;
+ }
+
+ if let Some(func_str) = &self.query_func_bindings {
+ let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
+
+ if !seqs.is_empty() {
+ writeln!(
+ context.stdout(),
+ "{func_str} can be invoked via {}.",
+ seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
+ )?;
+ } else {
+ writeln!(context.stdout(), "{func_str} is not bound to any keys.")?;
+ return Ok(ExecutionResult::general_error());
+ }
+ }
+
+ if let Some(func_str) = &self.remove_func_bindings {
+ let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
+
+ for seq in found_seqs {
+ let _ = bindings.try_unbind(seq);
+ }
+ }
+
+ if let Some(key_seq_str) = &self.remove_key_seq_binding {
+ let key_seq = parse_key_sequence(key_seq_str)?;
+ let _ = bindings.try_unbind(key_seq);
+ }
+
+ if self.bindings_file.is_some() {
+ return Err(BindError::Unimplemented("bind -f"));
+ }
+
+ if self.list_key_seq_bindings {
+ for (seq, action) in &bindings.get_current() {
+ let KeyAction::ShellCommand(cmd) = action else {
+ continue;
+ };
+
+ writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?;
+ }
+ }
+
+ if !self.key_seq_bindings.is_empty() {
+ if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
+ // NOTE(vi): Quietly ignore since we don't support vi mode.
+ return Ok(ExecutionResult::success());
+ }
+
+ for key_seq_and_command in &self.key_seq_bindings {
+ let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?;
+ bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?;
+ }
+ }
+
+ if let Some(key_sequence) = &self.key_sequence {
+ if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
+ // NOTE(vi): Quietly ignore since we don't support vi mode.
+ return Ok(ExecutionResult::success());
+ }
+
+ let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?;
+ bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?;
+ }
+
+ drop(bindings);
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+fn parse_key_sequence(input: &str) -> Result {
+ // First trim any whitespace.
+ let input = input.trim();
+
+ let parsed = brush_parser::readline_binding::parse_key_sequence(input)?;
+ let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?;
+
+ Ok(abstract_seq)
+}
+
+fn parse_key_sequence_and_shell_command(
+ input: &str,
+) -> Result<(interfaces::KeySequence, String), BindError> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "parsing key binding entry: '{input}'"
+ );
+
+ // First trim any whitespace.
+ let input = input.trim();
+
+ // This should be something of the form:
+ // "KEY-SEQUENCE": SHELL-COMMAND
+ let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?;
+ let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
+
+ Ok((abstract_seq, binding.shell_cmd))
+}
+
+#[derive(Debug)]
+#[allow(dead_code, reason = "not all variants implemented yet")]
+enum BindableReadlineTarget {
+ Function(interfaces::InputFunction),
+ Macro(interfaces::KeySequence),
+}
+
+fn parse_key_sequence_and_readline_target(
+ input: &str,
+) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "parsing key binding entry: '{input}'"
+ );
+
+ // First trim any whitespace.
+ let input = input.trim();
+
+ // This should be of one of these forms:
+ // "KEY-SEQUENCE":function-name
+ // "KEY-SEQUENCE":readline-command
+ let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?;
+ let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
+
+ match binding.target {
+ brush_parser::readline_binding::ReadlineTarget::Function(func_name) => {
+ let func = parse_readline_function(func_name.as_str())?;
+ Ok((abstract_seq, BindableReadlineTarget::Function(func)))
+ }
+ brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => {
+ let parsed_target =
+ brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?;
+ let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?;
+ Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target)))
+ }
+ }
+}
+
+fn bind_key_sequence_to_shell_cmd(
+ bindings: &mut dyn interfaces::KeyBindings,
+ key_sequence: interfaces::KeySequence,
+ command: String,
+) -> Result<(), BindError> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => command '{command}'"
+ );
+
+ bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?;
+
+ Ok(())
+}
+
+fn bind_key_sequence_to_readline_target(
+ bindings: &mut dyn interfaces::KeyBindings,
+ key_sequence: interfaces::KeySequence,
+ target: BindableReadlineTarget,
+) -> Result<(), BindError> {
+ match target {
+ BindableReadlineTarget::Function(func) => {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => readline function '{func}'"
+ );
+
+ if matches!(func, interfaces::InputFunction::ViEditingMode) {
+ // NOTE(vi): We don't support vi mode; silently ignore.
+ return Ok(());
+ }
+
+ bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?;
+ Ok(())
+ }
+ BindableReadlineTarget::Macro(cmd_macro) => {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
+ );
+
+ bindings.define_macro(key_sequence, cmd_macro)?;
+ Ok(())
+ }
+ }
+}
+
+fn key_sequence_to_abstract_strokes(
+ seq: &brush_parser::readline_binding::KeySequence,
+) -> Result {
+ let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
+
+ // Lift from key codes to abstract keys.
+ let mut abstract_strokes = vec![];
+ let mut key_code_bytes = vec![];
+ let mut uninterpretable = false;
+ for mut phys_stroke in phys_strokes {
+ let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice());
+
+ // If we couldn't interpret it directly but we see it starts with the escape character,
+ // try to see if we can parse it as an Alt+ sequence.
+ if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' {
+ key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]);
+ if key.is_some() {
+ phys_stroke.meta = true;
+ }
+ }
+
+ // When storing as bytes, apply control modifier to the key code.
+ let mut raw_bytes = phys_stroke.key_code.clone();
+ if phys_stroke.control {
+ for byte in &mut raw_bytes {
+ // Control characters are computed by ANDing with 0x1F
+ *byte &= 0x1F;
+ }
+ }
+ key_code_bytes.push(raw_bytes);
+
+ if let Some(key) = key {
+ abstract_strokes.push(interfaces::KeyStroke {
+ alt: phys_stroke.meta,
+ control: phys_stroke.control,
+ shift: false,
+ key,
+ });
+ } else {
+ uninterpretable = true;
+ }
+ }
+
+ if uninterpretable {
+ Ok(interfaces::KeySequence::Bytes(key_code_bytes))
+ } else {
+ Ok(interfaces::KeySequence::Strokes(abstract_strokes))
+ }
+}
+
+fn parse_readline_function(func_name: &str) -> Result {
+ interfaces::InputFunction::from_str(func_name)
+ .map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned()))
+}
+
+const fn to_onoff(value: bool) -> &'static str {
+ if value { "on" } else { "off" }
+}
+
+fn display_funcs_and_bindings(
+ bindings: &dyn interfaces::KeyBindings,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ reusable: bool,
+) -> Result<(), BindError> {
+ let mut sequences_by_func: HashMap> = HashMap::new();
+ for (seq, action) in &bindings.get_current() {
+ let KeyAction::DoInputFunction(func) = action else {
+ continue;
+ };
+
+ sequences_by_func
+ .entry(func.clone())
+ .or_default()
+ .push(seq.clone());
+ }
+
+ let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string());
+
+ for func in sorted_funcs {
+ match sequences_by_func.get(&func) {
+ Some(seqs) if reusable => {
+ for seq in seqs {
+ writeln!(context.stdout(), "\"{seq}\": {func}")?;
+ }
+ }
+ Some(seqs) => {
+ writeln!(
+ context.stdout(),
+ "{func} can be found on {}.",
+ seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
+ )?;
+ }
+ None if reusable => {
+ writeln!(context.stdout(), "# {func} (not bound)")?;
+ }
+ None => {
+ writeln!(context.stdout(), "{func} is not bound to any keys")?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn display_macros(
+ bindings: &dyn interfaces::KeyBindings,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ reusable: bool,
+) -> Result<(), BindError> {
+ for (left, right) in bindings.get_macros() {
+ if reusable {
+ writeln!(context.stdout(), "\"{left}\": \"{right}\"")?;
+ } else {
+ writeln!(context.stdout(), "{left} outputs {right}")?;
+ }
+ }
+
+ Ok(())
+}
+
+fn find_key_seqs_bound_to_function(
+ bindings: &dyn interfaces::KeyBindings,
+ func_str: &str,
+) -> Result, BindError> {
+ let Ok(func_to_find) = InputFunction::from_str(func_str) else {
+ return Err(BindError::UnknownFunction(func_str.to_owned()));
+ };
+
+ let mut found_seqs = vec![];
+
+ for (seq, action) in &bindings.get_current() {
+ if let KeyAction::DoInputFunction(func) = action
+ && *func == func_to_find
+ {
+ found_seqs.push(seq.clone());
+ }
+ }
+
+ Ok(found_seqs)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::{assert_eq, assert_matches};
+
+ #[test]
+ fn parse_example_key_sequence_and_readline_func() {
+ let (key_seq, target) =
+ parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap();
+
+ assert_eq!(
+ key_seq,
+ interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
+ alt: false,
+ control: true,
+ shift: false,
+ key: interfaces::Key::Character('a'),
+ }])
+ );
+
+ assert_matches!(
+ target,
+ BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine)
+ );
+ }
+
+ #[test]
+ fn parse_escape_char_key_binding() {
+ let (key_seq, target) =
+ parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap();
+
+ assert_eq!(
+ key_seq,
+ interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
+ alt: true,
+ control: false,
+ shift: false,
+ key: interfaces::Key::Character('r'),
+ }])
+ );
+
+ assert_matches!(
+ target,
+ BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars)
+ );
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/break_.rs b/local/recipes/shells/brush/source/brush-builtins/src/break_.rs
new file mode 100644
index 0000000000..d16a03948b
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/break_.rs
@@ -0,0 +1,34 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
+
+/// Breaks out of a control-flow loop.
+#[derive(Parser)]
+pub(crate) struct BreakCommand {
+ /// If specified, indicates which nested loop to break out of.
+ #[clap(default_value_t = 1)]
+ which_loop: i8,
+}
+
+impl builtins::Command for BreakCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ _context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // If specified, which_loop needs to be positive.
+ if self.which_loop <= 0 {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ let mut result = ExecutionResult::success();
+
+ result.next_control_flow = ExecutionControlFlow::BreakLoop {
+ #[expect(clippy::cast_sign_loss)]
+ levels: (self.which_loop - 1) as usize,
+ };
+
+ Ok(result)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/builder.rs b/local/recipes/shells/brush/source/brush-builtins/src/builder.rs
new file mode 100644
index 0000000000..291ecc6d84
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/builder.rs
@@ -0,0 +1,20 @@
+use crate::BuiltinSet;
+
+/// Extension trait that simplifies adding default builtins to a shell builder.
+pub trait ShellBuilderExt {
+ /// Add default builtins to the shell being built.
+ ///
+ /// # Arguments
+ ///
+ /// * `set` - The well-known set of built-ins to add.
+ #[must_use]
+ fn default_builtins(self, set: BuiltinSet) -> Self;
+}
+
+impl ShellBuilderExt
+ for brush_core::ShellBuilder
+{
+ fn default_builtins(self, set: BuiltinSet) -> Self {
+ self.builtins(crate::default_builtins(set))
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs b/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs
new file mode 100644
index 0000000000..271f0633c2
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs
@@ -0,0 +1,45 @@
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Directly invokes a built-in, without going through typical search order.
+#[derive(Default, Parser)]
+pub(crate) struct BuiltinCommand {
+ #[clap(skip)]
+ args: Vec,
+}
+
+impl builtins::DeclarationCommand for BuiltinCommand {
+ fn set_declarations(&mut self, args: Vec) {
+ self.args = args;
+ }
+}
+
+impl builtins::Command for BuiltinCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.args.is_empty() {
+ return Ok(ExecutionResult::success());
+ }
+
+ let args: Vec<_> = self.args.iter().skip(1).cloned().collect();
+ if args.is_empty() {
+ return Ok(ExecutionResult::success());
+ }
+
+ let builtin_name = args[0].to_string();
+
+ if let Some(builtin) = context.shell.builtins().get(&builtin_name)
+ && !builtin.disabled
+ {
+ context.command_name = builtin_name;
+ (builtin.execute_func)(context, args).await
+ } else {
+ Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
+ }
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/caller.rs b/local/recipes/shells/brush/source/brush-builtins/src/caller.rs
new file mode 100644
index 0000000000..58c7b3a849
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/caller.rs
@@ -0,0 +1,55 @@
+use brush_core::{ExecutionResult, builtins, callstack};
+use clap::Parser;
+use std::io::Write;
+
+/// Return the context of the current subroutine call.
+#[derive(Parser)]
+pub(crate) struct CallerCommand {
+ /// The number of call frames to go back.
+ expr: Option,
+}
+
+impl builtins::Command for CallerCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let stack = context.shell.call_stack();
+
+ // See how far back we need to look. Frame N represents the Nth caller
+ // (e.g., 0 = immediate caller, 1 = caller's caller, etc.).
+ let expr = self.expr.unwrap_or(0);
+
+ // Get all frames into a vector we can easily index into.
+ let frames: Vec<_> = stack
+ .iter()
+ .filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script())
+ .collect();
+
+ // Look for the last-known location in the parent of frame N.
+ let Some(calling_frame) = frames.get(expr + 1) else {
+ return Ok(ExecutionResult::general_error());
+ };
+
+ let line = calling_frame.current_line().unwrap_or(1);
+ let filename = &calling_frame.source_info.source;
+
+ // When the expr is provided, we display "LINE FUNCTION_NAME FILENAME"
+ // When the expr is omitted, we only display "LINE FILENAME"
+ if self.expr.is_some() {
+ let function_name = match &calling_frame.frame_type {
+ callstack::FrameType::Function(func_call) => func_call.name(),
+ callstack::FrameType::Script(..) => "source".into(),
+ _ => "".into(),
+ };
+
+ writeln!(context.stdout(), "{line} {function_name} {filename}")?;
+ } else {
+ writeln!(context.stdout(), "{line} {filename}")?;
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/cd.rs b/local/recipes/shells/brush/source/brush-builtins/src/cd.rs
new file mode 100644
index 0000000000..fdae4ca69e
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/cd.rs
@@ -0,0 +1,97 @@
+use std::io::Write;
+use std::path::PathBuf;
+
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins, error};
+
+/// Change the current shell working directory.
+#[derive(Parser)]
+pub(crate) struct CdCommand {
+ /// Force following symlinks.
+ #[arg(short = 'L', overrides_with = "use_physical_dir")]
+ force_follow_symlinks: bool,
+
+ /// Use physical dir structure without following symlinks.
+ #[arg(short = 'P', overrides_with = "force_follow_symlinks")]
+ use_physical_dir: bool,
+
+ /// Exit with non zero exit status if current working directory resolution fails.
+ #[arg(short = 'e')]
+ exit_on_failed_cwd_resolution: bool,
+
+ /// Show file with extended attributes as a dir with extended
+ /// attributes.
+ #[arg(short = '@')]
+ file_with_xattr_as_dir: bool,
+
+ /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is
+ /// converted to $OLDPWD.
+ target_dir: Option,
+}
+
+impl builtins::Command for CdCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // TODO(cd): implement 'cd -@'
+ if self.file_with_xattr_as_dir {
+ return error::unimp("cd -@");
+ }
+
+ let mut should_print = false;
+ let mut target_dir = if let Some(target_dir) = &self.target_dir {
+ // `cd -', equivalent to `cd $OLDPWD'
+ if target_dir.as_os_str() == "-" {
+ should_print = true;
+ if let Some(oldpwd) = context.shell.env_str("OLDPWD") {
+ PathBuf::from(oldpwd.to_string())
+ } else {
+ writeln!(context.stderr(), "OLDPWD not set")?;
+ return Ok(ExecutionResult::general_error());
+ }
+ } else {
+ // TODO(cd): remove clone, and use temporary lifetime extension after rust 1.75
+ target_dir.clone()
+ }
+ // `cd' without arguments is equivalent to `cd $HOME'
+ } else {
+ if let Some(home_var) = context.shell.env_str("HOME") {
+ PathBuf::from(home_var.to_string())
+ } else {
+ writeln!(context.stderr(), "HOME not set")?;
+ return Ok(ExecutionResult::general_error());
+ }
+ };
+
+ if self.use_physical_dir
+ || context
+ .shell
+ .options()
+ .do_not_resolve_symlinks_when_changing_dir
+ {
+ // -e is only relevant in physical mode.
+ if self.exit_on_failed_cwd_resolution {
+ return error::unimp("cd -e");
+ }
+
+ target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
+ }
+
+ context.shell.set_working_dir(&target_dir)?;
+
+ // Bash compatibility
+ // https://www.gnu.org/software/bash/manual/bash.html#index-cd
+ // If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and
+ // the directory change is successful, the absolute pathname of the new working
+ // directory is written to the standard output.
+ if should_print {
+ writeln!(context.stdout(), "{}", target_dir.display())?;
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/colon.rs b/local/recipes/shells/brush/source/brush-builtins/src/colon.rs
new file mode 100644
index 0000000000..7d5f749475
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/colon.rs
@@ -0,0 +1,28 @@
+use brush_core::{ExecutionResult, builtins, error};
+
+/// No-op command.
+pub(crate) struct ColonCommand {}
+
+impl builtins::SimpleCommand for ColonCommand {
+ fn get_content(
+ _name: &str,
+ content_type: builtins::ContentType,
+ _options: &builtins::ContentOptions,
+ ) -> Result {
+ match content_type {
+ builtins::ContentType::DetailedHelp => {
+ Ok("Null command; always returns success.".into())
+ }
+ builtins::ContentType::ShortUsage => Ok(":: :".into()),
+ builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
+ builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
+ }
+ }
+
+ fn execute, S: AsRef>(
+ _context: brush_core::ExecutionContext<'_, SE>,
+ _args: I,
+ ) -> Result {
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/command.rs b/local/recipes/shells/brush/source/brush-builtins/src/command.rs
new file mode 100644
index 0000000000..5401181b03
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/command.rs
@@ -0,0 +1,160 @@
+use clap::Parser;
+use std::{fmt::Display, io::Write, path::Path};
+
+use brush_core::{
+ ExecutionResult, builtins, commands, pathsearch,
+ sys::{self, fs::PathExt},
+};
+
+/// Directly invokes an external command, without going through typical search order.
+#[derive(Default, Parser)]
+pub(crate) struct CommandCommand {
+ /// Use default PATH value.
+ #[arg(short = 'p')]
+ pub use_default_path: bool,
+
+ /// Display a short description of the command.
+ #[arg(short = 'v')]
+ pub print_description: bool,
+
+ /// Display a more verbose description of the command.
+ #[arg(short = 'V')]
+ pub print_verbose_description: bool,
+
+ /// Command and arguments.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ pub command_and_args: Vec,
+}
+
+impl CommandCommand {
+ fn command(&self) -> Option<&str> {
+ self.command_and_args.first().map(|s| s.as_str())
+ }
+}
+
+impl builtins::Command for CommandCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // Silently exit if no command was provided.
+ if let Some(command_name) = self.command() {
+ if self.print_description || self.print_verbose_description {
+ if let Some(found_cmd) =
+ Self::try_find_command(context.shell, command_name, self.use_default_path)
+ {
+ if self.print_description {
+ writeln!(context.stdout(), "{found_cmd}")?;
+ } else {
+ match found_cmd {
+ FoundCommand::Builtin(_name) => {
+ writeln!(context.stdout(), "{command_name} is a shell builtin")?;
+ }
+ FoundCommand::External(path) => {
+ writeln!(context.stdout(), "{command_name} is {path}")?;
+ }
+ }
+ }
+ Ok(ExecutionResult::success())
+ } else {
+ if self.print_verbose_description {
+ writeln!(context.stderr(), "command: {command_name}: not found")?;
+ }
+ Ok(ExecutionResult::general_error())
+ }
+ } else {
+ self.execute_command(context, command_name, self.use_default_path)
+ .await
+ }
+ } else {
+ Ok(ExecutionResult::success())
+ }
+ }
+}
+
+enum FoundCommand {
+ Builtin(String),
+ External(String),
+}
+
+impl Display for FoundCommand {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Builtin(name) => write!(f, "{name}"),
+ Self::External(path) => write!(f, "{path}"),
+ }
+ }
+}
+
+impl CommandCommand {
+ fn try_find_command(
+ shell: &mut brush_core::Shell,
+ command_name: &str,
+ use_default_path: bool,
+ ) -> Option {
+ // Look in path.
+ if sys::fs::contains_path_separator(command_name) {
+ let candidate_path = shell.absolute_path(Path::new(command_name));
+ if candidate_path.executable() {
+ Some(FoundCommand::External(
+ candidate_path.to_string_lossy().to_string(),
+ ))
+ } else {
+ None
+ }
+ } else {
+ if let Some(builtin_cmd) = shell.builtins().get(command_name)
+ && !builtin_cmd.disabled
+ {
+ return Some(FoundCommand::Builtin(command_name.to_owned()));
+ }
+
+ if use_default_path {
+ let dirs = sys::fs::get_default_standard_utils_paths();
+
+ pathsearch::search_for_executable(dirs.iter(), command_name)
+ .next()
+ .map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
+ } else {
+ shell
+ .find_first_executable_in_path_using_cache(command_name)
+ .map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
+ }
+ }
+ }
+
+ async fn execute_command(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ command_name: &str,
+ use_default_path: bool,
+ ) -> Result {
+ command_name.clone_into(&mut context.command_name);
+ let command_and_args = self
+ .command_and_args
+ .iter()
+ .map(brush_core::CommandArg::from);
+
+ let path_dirs = if use_default_path {
+ Some(sys::fs::get_default_standard_utils_paths())
+ } else {
+ None
+ };
+
+ let mut cmd = commands::SimpleCommand::new(
+ commands::ShellForCommand::ParentShell(context.shell),
+ context.params,
+ context.command_name,
+ command_and_args,
+ );
+ cmd.use_functions = false;
+ cmd.path_dirs = path_dirs;
+
+ let spawn_result = cmd.execute().await?;
+ let wait_result = spawn_result.wait().await?;
+
+ Ok(wait_result.into())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/complete.rs b/local/recipes/shells/brush/source/brush-builtins/src/complete.rs
new file mode 100644
index 0000000000..32cec71b0e
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/complete.rs
@@ -0,0 +1,647 @@
+use clap::Parser;
+use std::collections::HashMap;
+use std::fmt::Write as _;
+use std::io::Write;
+
+use brush_core::completion::{self, CompleteAction, CompleteOption, Spec};
+use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape};
+
+#[derive(Parser)]
+struct CommonCompleteCommandArgs {
+ /// Options governing the behavior of completions.
+ #[arg(short = 'o')]
+ options: Vec,
+
+ /// Actions to apply to generate completions.
+ #[arg(short = 'A')]
+ actions: Vec,
+
+ /// File glob pattern to be expanded to generate completions.
+ #[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")]
+ glob_pattern: Option,
+
+ /// List of words that will be considered as completions.
+ #[arg(short = 'W', allow_hyphen_values = true)]
+ word_list: Option,
+
+ /// Name of a shell function to invoke to generate completions.
+ #[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")]
+ function_name: Option,
+
+ /// Command to execute to generate completions.
+ #[arg(short = 'C', allow_hyphen_values = true)]
+ command: Option,
+
+ /// Pattern used as filter for completions.
+ #[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")]
+ filter_pattern: Option,
+
+ /// Prefix pattern used as filter for completions.
+ #[arg(short = 'P', allow_hyphen_values = true)]
+ prefix: Option,
+
+ /// Suffix pattern used as filter for completions.
+ #[arg(short = 'S', allow_hyphen_values = true)]
+ suffix: Option,
+
+ /// Complete with valid aliases.
+ #[arg(short = 'a')]
+ action_alias: bool,
+
+ /// Complete with names of shell builtins.
+ #[arg(short = 'b')]
+ action_builtin: bool,
+
+ /// Complete with names of executable commands.
+ #[arg(short = 'c')]
+ action_command: bool,
+
+ /// Complete with directory names.
+ #[arg(short = 'd')]
+ action_directory: bool,
+
+ /// Complete with names of exported shell variables.
+ #[arg(short = 'e')]
+ action_exported: bool,
+
+ /// Complete with filenames.
+ #[arg(short = 'f')]
+ action_file: bool,
+
+ /// Complete with valid user groups.
+ #[arg(short = 'g')]
+ action_group: bool,
+
+ /// Complete with job specs.
+ #[arg(short = 'j')]
+ action_job: bool,
+
+ /// Complete with keywords.
+ #[arg(short = 'k')]
+ action_keyword: bool,
+
+ /// Complete with names of system services.
+ #[arg(short = 's')]
+ action_service: bool,
+
+ /// Complete with valid usernames.
+ #[arg(short = 'u')]
+ action_user: bool,
+
+ /// Complete with names of shell variables.
+ #[arg(short = 'v')]
+ action_variable: bool,
+}
+
+impl CommonCompleteCommandArgs {
+ fn create_spec(&self, extglob_enabled: bool) -> completion::Spec {
+ let filter_pattern_excludes;
+ let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() {
+ // If the pattern starts with a '!' that's not the start of an extglob pattern,
+ // then we invert.
+ if let Some(remaining_pattern) = filter_pattern.strip_prefix('!') {
+ if !extglob_enabled || !remaining_pattern.starts_with('(') {
+ filter_pattern_excludes = false;
+ Some(remaining_pattern.to_owned())
+ } else {
+ filter_pattern_excludes = true;
+ Some(filter_pattern.to_owned())
+ }
+ } else {
+ filter_pattern_excludes = true;
+ Some(filter_pattern.clone())
+ }
+ } else {
+ filter_pattern_excludes = false;
+ None
+ };
+
+ let mut spec = completion::Spec {
+ options: completion::GenerationOptions::default(),
+ actions: self.resolve_actions(),
+ glob_pattern: self.glob_pattern.clone(),
+ word_list: self.word_list.clone(),
+ function_name: self.function_name.clone(),
+ command: self.command.clone(),
+ filter_pattern,
+ filter_pattern_excludes,
+ prefix: self.prefix.clone(),
+ suffix: self.suffix.clone(),
+ };
+
+ for option in &self.options {
+ match option {
+ CompleteOption::BashDefault => spec.options.bash_default = true,
+ CompleteOption::Default => spec.options.default = true,
+ CompleteOption::DirNames => spec.options.dir_names = true,
+ CompleteOption::FileNames => spec.options.file_names = true,
+ CompleteOption::NoQuote => spec.options.no_quote = true,
+ CompleteOption::NoSort => spec.options.no_sort = true,
+ CompleteOption::NoSpace => spec.options.no_space = true,
+ CompleteOption::PlusDirs => spec.options.plus_dirs = true,
+ }
+ }
+
+ spec
+ }
+
+ fn resolve_actions(&self) -> Vec {
+ let mut actions = self.actions.clone();
+
+ actions.extend(
+ [
+ (self.action_alias, CompleteAction::Alias),
+ (self.action_builtin, CompleteAction::Builtin),
+ (self.action_command, CompleteAction::Command),
+ (self.action_directory, CompleteAction::Directory),
+ (self.action_exported, CompleteAction::Export),
+ (self.action_file, CompleteAction::File),
+ (self.action_group, CompleteAction::Group),
+ (self.action_job, CompleteAction::Job),
+ (self.action_keyword, CompleteAction::Keyword),
+ (self.action_service, CompleteAction::Service),
+ (self.action_user, CompleteAction::User),
+ (self.action_variable, CompleteAction::Variable),
+ ]
+ .into_iter()
+ .filter_map(|(enabled, action)| enabled.then_some(action)),
+ );
+
+ actions
+ }
+}
+
+/// Configure programmable command completion.
+#[derive(Parser)]
+pub(crate) struct CompleteCommand {
+ /// Display registered completion settings.
+ #[arg(short = 'p')]
+ print: bool,
+
+ /// Remove the completion settings associated with the given command.
+ #[arg(short = 'r')]
+ remove: bool,
+
+ /// Apply these settings to the default completion scenario.
+ #[arg(short = 'D')]
+ use_as_default: bool,
+
+ /// Apply these settings to completion of empty lines.
+ #[arg(short = 'E')]
+ use_for_empty_line: bool,
+
+ /// Apply these settings to completion of the initial word of the input line.
+ #[arg(short = 'I')]
+ use_for_initial_word: bool,
+
+ #[clap(flatten)]
+ common_args: CommonCompleteCommandArgs,
+
+ names: Vec,
+}
+
+impl builtins::Command for CompleteCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ // If -D, -E, or -I are specified, then any names provided are ignored.
+ if self.use_as_default
+ || self.use_for_empty_line
+ || self.use_for_initial_word
+ || self.names.is_empty()
+ {
+ self.process_global(&mut context)?;
+ } else {
+ for name in &self.names {
+ if !self.try_process_for_command(&mut context, name.as_str())? {
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl CompleteCommand {
+ fn process_global(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result<(), brush_core::Error> {
+ // Read options before taking mutable borrow on completion_config
+ let extended_globbing = context.shell.options().extended_globbing;
+
+ // These are processed in an intentional order.
+ let special_option_name;
+ let target_spec = if self.use_as_default {
+ special_option_name = "-D";
+ Some(&mut context.shell.completion_config_mut().default)
+ } else if self.use_for_empty_line {
+ special_option_name = "-E";
+ Some(&mut context.shell.completion_config_mut().empty_line)
+ } else if self.use_for_initial_word {
+ special_option_name = "-I";
+ Some(&mut context.shell.completion_config_mut().initial_word)
+ } else {
+ special_option_name = "";
+ None
+ };
+
+ // Treat 'complete' with no options the same as 'complete -p'.
+ if self.print || (!self.remove && target_spec.is_none()) {
+ if let Some(target_spec) = target_spec {
+ if let Some(existing_spec) = target_spec {
+ let existing_spec = existing_spec.clone();
+ Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
+ } else {
+ return error::unimp("special spec not found");
+ }
+ } else {
+ for (command_name, spec) in context.shell.completion_config().iter() {
+ Self::display_spec(context, None, Some(command_name.as_str()), spec)?;
+ }
+ }
+ } else if self.remove {
+ if let Some(target_spec) = target_spec {
+ let mut new_spec = None;
+ std::mem::swap(&mut new_spec, target_spec);
+ } else {
+ context.shell.completion_config_mut().clear();
+ }
+ } else {
+ if let Some(target_spec) = target_spec {
+ let mut new_spec = Some(self.common_args.create_spec(extended_globbing));
+ std::mem::swap(&mut new_spec, target_spec);
+ } else {
+ return error::unimp("set unspecified spec");
+ }
+ }
+
+ Ok(())
+ }
+
+ fn try_display_spec_for_command(
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ name: &str,
+ ) -> Result {
+ if let Some(spec) = context.shell.completion_config().get(name) {
+ Self::display_spec(context, None, Some(name), spec)?;
+ Ok(true)
+ } else {
+ writeln!(context.stderr(), "no completion found for command")?;
+ Ok(false)
+ }
+ }
+
+ #[expect(clippy::too_many_lines)]
+ fn display_spec(
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ special_name: Option<&str>,
+ command_name: Option<&str>,
+ spec: &Spec,
+ ) -> Result<(), brush_core::Error> {
+ let mut s = String::from("complete");
+
+ if let Some(special_name) = special_name {
+ s.push(' ');
+ s.push_str(special_name);
+ }
+
+ for action in &spec.actions {
+ s.push(' ');
+
+ let action_str = match action {
+ CompleteAction::Alias => "-a",
+ CompleteAction::ArrayVar => "-A arrayvar",
+ CompleteAction::Binding => "-A binding",
+ CompleteAction::Builtin => "-b",
+ CompleteAction::Command => "-c",
+ CompleteAction::Directory => "-d",
+ CompleteAction::Disabled => "-A disabled",
+ CompleteAction::Enabled => "-A enabled",
+ CompleteAction::Export => "-e",
+ CompleteAction::File => "-f",
+ CompleteAction::Function => "-A function",
+ CompleteAction::Group => "-g",
+ CompleteAction::HelpTopic => "-A helptopic",
+ CompleteAction::HostName => "-A hostname",
+ CompleteAction::Job => "-j",
+ CompleteAction::Keyword => "-k",
+ CompleteAction::Running => "-A running",
+ CompleteAction::Service => "-s",
+ CompleteAction::SetOpt => "-A setopt",
+ CompleteAction::ShOpt => "-A shopt",
+ CompleteAction::Signal => "-A signal",
+ CompleteAction::Stopped => "-A stopped",
+ CompleteAction::User => "-u",
+ CompleteAction::Variable => "-v",
+ };
+
+ s.push_str(action_str);
+ }
+
+ if spec.options.bash_default {
+ s.push_str(" -o bashdefault");
+ }
+ if spec.options.default {
+ s.push_str(" -o default");
+ }
+ if spec.options.dir_names {
+ s.push_str(" -o dirnames");
+ }
+ if spec.options.file_names {
+ s.push_str(" -o filenames");
+ }
+ if spec.options.no_quote {
+ s.push_str(" -o noquote");
+ }
+ if spec.options.no_sort {
+ s.push_str(" -o nosort");
+ }
+ if spec.options.no_space {
+ s.push_str(" -o nospace");
+ }
+ if spec.options.plus_dirs {
+ s.push_str(" -o plusdirs");
+ }
+
+ if let Some(glob_pattern) = &spec.glob_pattern {
+ write!(
+ s,
+ " -G {}",
+ escape::force_quote(glob_pattern, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(word_list) = &spec.word_list {
+ write!(
+ s,
+ " -W {}",
+ escape::force_quote(word_list, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(function_name) = &spec.function_name {
+ write!(s, " -F {function_name}")?;
+ }
+ if let Some(command) = &spec.command {
+ write!(
+ s,
+ " -C {}",
+ escape::force_quote(command, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(filter_pattern) = &spec.filter_pattern {
+ write!(
+ s,
+ " -X {}",
+ escape::force_quote(filter_pattern, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(prefix) = &spec.prefix {
+ write!(
+ s,
+ " -P {}",
+ escape::force_quote(prefix, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(suffix) = &spec.suffix {
+ write!(
+ s,
+ " -S {}",
+ escape::force_quote(suffix, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+
+ if let Some(command_name) = command_name {
+ s.push(' ');
+ s.push_str(command_name);
+ }
+
+ writeln!(context.stdout(), "{s}")?;
+
+ Ok(())
+ }
+
+ fn try_process_for_command(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ name: &str,
+ ) -> Result {
+ if self.print {
+ return Self::try_display_spec_for_command(context, name);
+ } else if self.remove {
+ let mut result = context.shell.completion_config_mut().remove(name);
+
+ if !result {
+ if context.shell.options().interactive {
+ writeln!(context.stderr(), "complete: {name}: not found")?;
+ } else {
+ // For some reason, this is not supposed to be treated as a failure
+ // in non-interactive execution.
+ result = true;
+ }
+ }
+
+ return Ok(result);
+ }
+
+ let config = self
+ .common_args
+ .create_spec(context.shell.options().extended_globbing);
+
+ context.shell.completion_config_mut().set(name, config);
+
+ Ok(true)
+ }
+}
+
+/// Generate command completions.
+#[derive(Parser)]
+pub(crate) struct CompGenCommand {
+ #[clap(flatten)]
+ common_args: CommonCompleteCommandArgs,
+
+ // N.B. The word can only start with a hyphen if it's after a --.
+ word: Option,
+}
+
+impl builtins::Command for CompGenCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut spec = self
+ .common_args
+ .create_spec(context.shell.options().extended_globbing);
+ spec.options.no_sort = true;
+
+ let token_to_complete = self.word.as_deref().unwrap_or_default();
+
+ // We unquote the token-to-be-completed before passing it to the completion system.
+ let unquoted_token = brush_parser::unquote_str(token_to_complete);
+
+ let completion_context = completion::Context {
+ token_to_complete: unquoted_token.as_str(),
+ preceding_token: None,
+ command_name: None,
+ token_index: 0,
+ tokens: &[&completion::CompletionToken {
+ text: token_to_complete,
+ start: 0,
+ }],
+ input_line: token_to_complete,
+ cursor_index: token_to_complete.len(),
+ trigger: completion::CompletionTrigger::Programmatic,
+ };
+
+ let result = spec
+ .get_completions(context.shell, &completion_context)
+ .await?;
+
+ match result {
+ completion::Answer::Candidates(candidates, _options) => {
+ // We are expected to return 1 if there are no candidates, even if no errors
+ // occurred along the way.
+ if candidates.is_empty() {
+ return Ok(ExecutionResult::general_error());
+ }
+
+ for candidate in candidates {
+ writeln!(context.stdout(), "{candidate}")?;
+ }
+ }
+ completion::Answer::RestartCompletionProcess => {
+ return error::unimp("restart completion");
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+/// Set programmable command completion options.
+#[derive(Parser)]
+pub(crate) struct CompOptCommand {
+ /// Update the default completion settings.
+ #[arg(short = 'D')]
+ update_default: bool,
+
+ /// Update the completion settings for empty lines.
+ #[arg(short = 'E')]
+ update_empty: bool,
+
+ /// Update the completion settings for the initial word of the input line.
+ #[arg(short = 'I')]
+ update_initial_word: bool,
+
+ /// Enable the specified option for selected completion scenarios.
+ #[arg(short = 'o', value_name = "OPT")]
+ enabled_options: Vec,
+ #[arg(long = concat!("+o"), hide = true)]
+ disabled_options: Vec,
+
+ /// If specified, scopes updates to completions of the named commands.
+ names: Vec,
+}
+
+impl builtins::Command for CompOptCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut options =
+ HashMap::with_capacity(self.disabled_options.len() + self.enabled_options.len());
+ for option in &self.disabled_options {
+ options.insert(option.clone(), false);
+ }
+ for option in &self.enabled_options {
+ options.insert(option.clone(), true);
+ }
+
+ if !self.names.is_empty() {
+ if self.update_default || self.update_empty || self.update_initial_word {
+ writeln!(
+ context.stderr(),
+ "compopt: cannot specify names with -D, -E, or -I"
+ )?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ for name in &self.names {
+ let spec = context.shell.completion_config_mut().get_or_add_mut(name);
+ Self::set_options_for_spec(spec, &options);
+ }
+ } else if self.update_default {
+ if let Some(spec) = &mut context.shell.completion_config_mut().default {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config_mut().default = Some(spec);
+ }
+ } else if self.update_empty {
+ if let Some(spec) = &mut context.shell.completion_config_mut().empty_line {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config_mut().empty_line = Some(spec);
+ }
+ } else if self.update_initial_word {
+ if let Some(spec) = &mut context.shell.completion_config_mut().initial_word {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config_mut().initial_word = Some(spec);
+ }
+ } else {
+ // If we got here, then we need to apply to any completion actively in-flight.
+ if let Some(in_flight_options) = context
+ .shell
+ .completion_config_mut()
+ .current_completion_options
+ .as_mut()
+ {
+ Self::set_options(in_flight_options, &options);
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+impl CompOptCommand {
+ fn set_options_for_spec<'a, I>(spec: &mut Spec, options: I)
+ where
+ I: IntoIterator,
+ {
+ Self::set_options(&mut spec.options, options);
+ }
+
+ fn set_options<'a, I>(target_options: &mut completion::GenerationOptions, options: I)
+ where
+ I: IntoIterator,
+ {
+ for (option, value) in options {
+ match option {
+ CompleteOption::BashDefault => target_options.bash_default = *value,
+ CompleteOption::Default => target_options.default = *value,
+ CompleteOption::DirNames => target_options.dir_names = *value,
+ CompleteOption::FileNames => target_options.file_names = *value,
+ CompleteOption::NoQuote => target_options.no_quote = *value,
+ CompleteOption::NoSort => target_options.no_sort = *value,
+ CompleteOption::NoSpace => target_options.no_space = *value,
+ CompleteOption::PlusDirs => target_options.plus_dirs = *value,
+ }
+ }
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs b/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs
new file mode 100644
index 0000000000..8634097a04
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs
@@ -0,0 +1,34 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
+
+/// Continue to the next iteration of a control-flow loop.
+#[derive(Parser)]
+pub(crate) struct ContinueCommand {
+ /// If specified, indicates which nested loop to continue to the next iteration of.
+ #[clap(default_value_t = 1)]
+ which_loop: i8,
+}
+
+impl builtins::Command for ContinueCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ _context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // If specified, which_loop needs to be positive.
+ if self.which_loop <= 0 {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ let mut result = ExecutionResult::success();
+
+ result.next_control_flow = ExecutionControlFlow::ContinueLoop {
+ #[expect(clippy::cast_sign_loss)]
+ levels: (self.which_loop - 1) as usize,
+ };
+
+ Ok(result)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/declare.rs b/local/recipes/shells/brush/source/brush-builtins/src/declare.rs
new file mode 100644
index 0000000000..11ee10427a
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/declare.rs
@@ -0,0 +1,644 @@
+use clap::Parser;
+use itertools::Itertools;
+use std::{io::Write, sync::LazyLock};
+
+use brush_core::{
+ ErrorKind, ExecutionResult, builtins,
+ env::{self, EnvironmentLookup, EnvironmentScope},
+ error,
+ parser::ast,
+ variables::{
+ self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
+ ShellVariableUpdateTransform,
+ },
+};
+
+crate::minus_or_plus_flag_arg!(
+ MakeIndexedArrayFlag,
+ 'a',
+ "Make the variable an indexed array."
+);
+crate::minus_or_plus_flag_arg!(
+ MakeAssociativeArrayFlag,
+ 'A',
+ "Make the variable an associative array."
+);
+crate::minus_or_plus_flag_arg!(
+ CapitalizeValueOnAssignmentFlag,
+ 'c',
+ "Enable capitalize-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
+crate::minus_or_plus_flag_arg!(
+ LowercaseValueOnAssignmentFlag,
+ 'l',
+ "Enable lowercase-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(
+ MakeNameRefFlag,
+ 'n',
+ "Mark the variable as a name reference"
+);
+crate::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
+crate::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
+crate::minus_or_plus_flag_arg!(
+ UppercaseValueOnAssignmentFlag,
+ 'u',
+ "Enable uppercase-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
+
+/// Display or update variables and their attributes.
+#[derive(Parser)]
+#[clap(override_usage = "declare [OPTIONS] [DECLARATIONS]...")]
+pub(crate) struct DeclareCommand {
+ /// Constrain to function names or definitions.
+ #[arg(short = 'f')]
+ function_names_or_defs_only: bool,
+
+ /// Constrain to function names only.
+ #[arg(short = 'F')]
+ function_names_only: bool,
+
+ /// Create global variable, if applicable.
+ #[arg(short = 'g')]
+ create_global: bool,
+
+ /// When creating a local variable that shadows another variable of the same name,
+ /// then initialize it with the contents and attributes of the variable being shadowed.
+ #[arg(short = 'I')]
+ locals_inherit_from_prev_scope: bool,
+
+ /// Display each item's attributes and values.
+ #[arg(short = 'p')]
+ print: bool,
+
+ //
+ // Attribute options
+ #[clap(flatten)] // -a
+ make_indexed_array: MakeIndexedArrayFlag,
+ #[clap(flatten)] // -A
+ make_associative_array: MakeAssociativeArrayFlag,
+ #[clap(flatten)] // -c
+ capitalize_value_on_assignment: CapitalizeValueOnAssignmentFlag,
+ #[clap(flatten)] // -i
+ make_integer: MakeIntegerFlag,
+ #[clap(flatten)] // -l
+ lowercase_value_on_assignment: LowercaseValueOnAssignmentFlag,
+ #[clap(flatten)] // -n
+ make_nameref: MakeNameRefFlag,
+ #[clap(flatten)] // -r
+ make_readonly: MakeReadonlyFlag,
+ #[clap(flatten)] // -t
+ make_traced: MakeTracedFlag,
+ #[clap(flatten)] // -u
+ uppercase_value_on_assignment: UppercaseValueOnAssignmentFlag,
+ #[clap(flatten)] // -x
+ make_exported: MakeExportedFlag,
+
+ //
+ // Declarations
+ //
+ // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
+ #[clap(skip)]
+ declarations: Vec,
+}
+
+#[derive(Clone, Copy)]
+enum DeclareVerb {
+ Declare,
+ Local,
+ Readonly,
+}
+
+impl builtins::DeclarationCommand for DeclareCommand {
+ fn set_declarations(&mut self, declarations: Vec) {
+ self.declarations = declarations;
+ }
+}
+
+impl builtins::Command for DeclareCommand {
+ fn takes_plus_options() -> bool {
+ true
+ }
+
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let verb = match context.command_name.as_str() {
+ "local" => DeclareVerb::Local,
+ "readonly" => DeclareVerb::Readonly,
+ _ => DeclareVerb::Declare,
+ };
+
+ if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() {
+ writeln!(context.stderr(), "can only be used in a function")?;
+ return Ok(ExecutionResult::general_error());
+ }
+
+ if self.locals_inherit_from_prev_scope {
+ return error::unimp("declare -I");
+ }
+
+ let mut result = ExecutionResult::success();
+ if !self.declarations.is_empty() {
+ for declaration in &self.declarations {
+ if self.print && !matches!(verb, DeclareVerb::Readonly) {
+ if !self.try_display_declaration(&context, declaration, verb)? {
+ result = ExecutionResult::general_error();
+ }
+ } else {
+ if !self.process_declaration(&mut context, declaration, verb)? {
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+ } else {
+ // Display matching declarations from the variable environment.
+ if !self.function_names_only && !self.function_names_or_defs_only {
+ self.display_matching_env_declarations(&context, verb)?;
+ }
+
+ // Do the same for functions.
+ if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly)
+ && (!self.print || self.function_names_only || self.function_names_or_defs_only)
+ {
+ self.display_matching_functions(&context)?;
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl DeclareCommand {
+ fn try_display_declaration(
+ &self,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ declaration: &brush_core::CommandArg,
+ verb: DeclareVerb,
+ ) -> Result {
+ let name = match declaration {
+ brush_core::CommandArg::String(s) => s,
+ brush_core::CommandArg::Assignment(_) => {
+ writeln!(context.stderr(), "declare: {declaration}: not found")?;
+ return Ok(false);
+ }
+ };
+
+ let lookup = if matches!(verb, DeclareVerb::Local) {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ if self.function_names_only || self.function_names_or_defs_only {
+ if let Some(func_registration) = context.shell.funcs().get(name) {
+ if self.function_names_only {
+ if self.print {
+ writeln!(context.stdout(), "declare -f {name}")?;
+ } else {
+ writeln!(context.stdout(), "{name}")?;
+ }
+ } else {
+ writeln!(context.stdout(), "{}", func_registration.definition())?;
+ }
+ Ok(true)
+ } else {
+ // For some reason, bash does not print an error message in this case.
+ Ok(false)
+ }
+ } else if let Some(variable) = context.shell.env().get_using_policy(name, lookup) {
+ let mut cs = variable.attribute_flags(context.shell);
+ if cs.is_empty() {
+ cs.push('-');
+ }
+
+ let resolved_value = variable.resolve_value(context.shell);
+ let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) {
+ ""
+ } else {
+ "="
+ };
+
+ writeln!(
+ context.stdout(),
+ "declare -{cs} {name}{separator_str}{}",
+ resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)?
+ )?;
+
+ Ok(true)
+ } else {
+ writeln!(context.stderr(), "declare: {name}: not found")?;
+ Ok(false)
+ }
+ }
+
+ fn process_declaration(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ declaration: &brush_core::CommandArg,
+ verb: DeclareVerb,
+ ) -> Result {
+ let create_var_local = matches!(verb, DeclareVerb::Local)
+ || (matches!(verb, DeclareVerb::Declare)
+ && context.shell.in_function()
+ && !self.create_global);
+
+ if self.function_names_or_defs_only || self.function_names_only {
+ return self.try_display_declaration(context, declaration, verb);
+ }
+
+ // Extract the variable name and the initial value being assigned (if any).
+ let (name, assigned_index, initial_value, name_is_array) =
+ Self::declaration_to_name_and_value(declaration)?;
+
+ // Special-case: `local -`
+ if name == "-" && matches!(verb, DeclareVerb::Local) {
+ // TODO(local): `local -` allows shadowing the current `set` options (i.e., $-), with
+ // subsequent updates getting discarded when the current local scope is popped.
+ tracing::warn!("not yet implemented: local -");
+ return Ok(true);
+ }
+
+ // Make sure it's a valid name.
+ if !env::valid_variable_name(name.as_str()) {
+ writeln!(
+ context.stderr(),
+ "{}: {name}: not a valid variable name",
+ context.command_name
+ )?;
+ return Ok(false);
+ }
+
+ // Figure out where we should look.
+ let lookup = if create_var_local {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ // Look up the variable.
+ if let Some(var) = context
+ .shell
+ .env_mut()
+ .get_mut_using_policy(name.as_str(), lookup)
+ {
+ if self.make_associative_array.is_some() {
+ var.convert_to_associative_array()?;
+ }
+ if self.make_indexed_array.is_some() {
+ var.convert_to_indexed_array()?;
+ }
+
+ self.apply_attributes_before_update(var)?;
+
+ if let Some(initial_value) = initial_value {
+ // We append if the declaration included an explicit index.
+ var.assign(initial_value, assigned_index.is_some())?;
+ }
+
+ self.apply_attributes_after_update(var, verb)?;
+ } else {
+ let unset_type = if self.make_indexed_array.is_some() {
+ ShellValueUnsetType::IndexedArray
+ } else if self.make_associative_array.is_some() {
+ ShellValueUnsetType::AssociativeArray
+ } else if name_is_array {
+ ShellValueUnsetType::IndexedArray
+ } else {
+ ShellValueUnsetType::Untyped
+ };
+
+ let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
+
+ self.apply_attributes_before_update(&mut var)?;
+
+ if let Some(initial_value) = initial_value {
+ var.assign(initial_value, false)?;
+ }
+
+ if context.shell.options().export_variables_on_modification && !var.value().is_array() {
+ var.export();
+ }
+
+ self.apply_attributes_after_update(&mut var, verb)?;
+
+ let scope = if create_var_local {
+ EnvironmentScope::Local
+ } else {
+ EnvironmentScope::Global
+ };
+
+ context.shell.env_mut().add(name, var, scope)?;
+ }
+
+ Ok(true)
+ }
+
+ fn declaration_to_name_and_value(
+ declaration: &brush_core::CommandArg,
+ ) -> Result<(String, Option, Option, bool), brush_core::Error> {
+ let name;
+ let assigned_index;
+ let initial_value;
+ let name_is_array;
+
+ match declaration {
+ brush_core::CommandArg::String(s) => {
+ // We need to handle the case of someone invoking `declare array[index]`.
+ // In such case, we ignore the index and treat it as a declaration of
+ // the array.
+ #[allow(
+ clippy::unwrap_in_result,
+ clippy::unwrap_used,
+ reason = "regex is valid and should not fail"
+ )]
+ static ARRAY_AND_INDEX_RE: LazyLock =
+ LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
+
+ if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
+ name = captures
+ .get(1)
+ .ok_or_else(|| {
+ brush_core::ErrorKind::InternalError("declaration parse error".into())
+ })?
+ .as_str()
+ .to_owned();
+
+ assigned_index = captures.get(2).map(|m| m.as_str().to_owned());
+ name_is_array = true;
+ } else {
+ name = s.clone();
+ assigned_index = None;
+ name_is_array = false;
+ }
+ initial_value = None;
+ }
+ brush_core::CommandArg::Assignment(assignment) => {
+ match &assignment.name {
+ ast::AssignmentName::VariableName(var_name) => {
+ name = var_name.to_owned();
+ assigned_index = None;
+ }
+ ast::AssignmentName::ArrayElementName(var_name, index) => {
+ if matches!(assignment.value, ast::AssignmentValue::Array(_)) {
+ return Err(ErrorKind::AssigningListToArrayMember.into());
+ }
+
+ name = var_name.to_owned();
+ assigned_index = Some(index.to_owned());
+ }
+ }
+
+ match &assignment.value {
+ ast::AssignmentValue::Scalar(s) => {
+ if let Some(index) = &assigned_index {
+ initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(vec![(
+ Some(index.to_owned()),
+ s.value.clone(),
+ )])));
+ name_is_array = true;
+ } else {
+ initial_value = Some(ShellValueLiteral::Scalar(s.value.clone()));
+ name_is_array = false;
+ }
+ }
+ ast::AssignmentValue::Array(a) => {
+ initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(
+ a.iter()
+ .map(|(i, v)| {
+ (i.as_ref().map(|w| w.value.clone()), v.value.clone())
+ })
+ .collect(),
+ )));
+ name_is_array = true;
+ }
+ }
+ }
+ }
+
+ Ok((name, assigned_index, initial_value, name_is_array))
+ }
+
+ fn display_matching_env_declarations(
+ &self,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ verb: DeclareVerb,
+ ) -> Result<(), brush_core::Error> {
+ //
+ // Dump all declarations. Use attribute flags to filter which variables are dumped.
+ //
+
+ // We start by excluding all variables that are not enumerable.
+ #[expect(clippy::type_complexity)]
+ let mut filters: Vec bool>> =
+ vec![Box::new(|(_, v)| v.is_enumerable())];
+
+ // Add filters depending on verb.
+ if matches!(verb, DeclareVerb::Readonly) {
+ filters.push(Box::new(|(_, v)| v.is_readonly()));
+ }
+
+ // Add filters depending on attribute flags.
+ if let Some(value) = self.make_indexed_array.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(v.value(), ShellValue::IndexedArray(_)) == value
+ }));
+ }
+ if let Some(value) = self.make_associative_array.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(v.value(), ShellValue::AssociativeArray(_)) == value
+ }));
+ }
+ if let Some(value) = self.make_integer.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_treated_as_integer() == value));
+ }
+ if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Capitalize
+ ) == value
+ }));
+ }
+ if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Lowercase
+ ) == value
+ }));
+ }
+ if let Some(value) = self.make_nameref.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_treated_as_nameref() == value));
+ }
+ if let Some(value) = self.make_readonly.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_readonly() == value));
+ }
+ if let Some(value) = self.make_readonly.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_trace_enabled() == value));
+ }
+ if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Uppercase
+ ) == value
+ }));
+ }
+ if let Some(value) = self.make_exported.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_exported() == value));
+ }
+
+ let iter_policy = if matches!(verb, DeclareVerb::Local) {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ // Iterate through an ordered list of all matching declarations tracked in the
+ // environment.
+ for (name, variable) in context
+ .shell
+ .env()
+ .iter_using_policy(iter_policy)
+ .filter(|pair| filters.iter().all(|f| f(*pair)))
+ .sorted_by_key(|v| v.0)
+ {
+ if self.print {
+ let mut cs = variable.attribute_flags(context.shell);
+ if cs.is_empty() {
+ cs.push('-');
+ }
+
+ let separator_str = if matches!(variable.value(), ShellValue::Unset(_)) {
+ ""
+ } else {
+ "="
+ };
+
+ writeln!(
+ context.stdout(),
+ "declare -{cs} {name}{separator_str}{}",
+ variable
+ .value()
+ .format(variables::FormatStyle::DeclarePrint, context.shell)?
+ )?;
+ } else {
+ writeln!(
+ context.stdout(),
+ "{name}={}",
+ variable
+ .value()
+ .format(variables::FormatStyle::Basic, context.shell)?
+ )?;
+ }
+ }
+
+ Ok(())
+ }
+
+ fn display_matching_functions(
+ &self,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result<(), brush_core::Error> {
+ for (name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
+ if self.function_names_only {
+ writeln!(context.stdout(), "declare -f {name}")?;
+ } else {
+ writeln!(context.stdout(), "{}", registration.definition())?;
+ }
+ }
+
+ Ok(())
+ }
+
+ #[expect(clippy::unnecessary_wraps)]
+ const fn apply_attributes_before_update(
+ &self,
+ var: &mut ShellVariable,
+ ) -> Result<(), brush_core::Error> {
+ if let Some(value) = self.make_integer.to_bool() {
+ if value {
+ var.treat_as_integer();
+ } else {
+ var.unset_treat_as_integer();
+ }
+ }
+ if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Capitalize);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Capitalize
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Lowercase);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Lowercase
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.make_nameref.to_bool() {
+ if value {
+ var.treat_as_nameref();
+ } else {
+ var.unset_treat_as_nameref();
+ }
+ }
+ if let Some(value) = self.make_traced.to_bool() {
+ if value {
+ var.enable_trace();
+ } else {
+ var.disable_trace();
+ }
+ }
+ if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Uppercase);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Uppercase
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.make_exported.to_bool() {
+ if value {
+ var.export();
+ } else {
+ var.unexport();
+ }
+ }
+
+ Ok(())
+ }
+
+ fn apply_attributes_after_update(
+ &self,
+ var: &mut ShellVariable,
+ verb: DeclareVerb,
+ ) -> Result<(), brush_core::Error> {
+ if matches!(verb, DeclareVerb::Readonly) {
+ var.set_readonly();
+ } else if let Some(value) = self.make_readonly.to_bool() {
+ if value {
+ var.set_readonly();
+ } else {
+ var.unset_readonly()?;
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs b/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs
new file mode 100644
index 0000000000..790310226a
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs
@@ -0,0 +1,101 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+#[derive(Debug, thiserror::Error)]
+pub(crate) enum DirError {
+ /// Directory stack is empty.
+ #[error("directory stack is empty")]
+ DirStackEmpty,
+
+ /// A shell error occurred.
+ #[error(transparent)]
+ ShellError(#[from] brush_core::Error),
+}
+
+impl From<&DirError> for brush_core::ExecutionExitCode {
+ fn from(value: &DirError) -> Self {
+ match value {
+ DirError::DirStackEmpty => Self::GeneralError,
+ DirError::ShellError(e) => e.into(),
+ }
+ }
+}
+
+impl brush_core::BuiltinError for DirError {}
+
+/// Manage the current directory stack.
+#[derive(Default, Parser)]
+pub(crate) struct DirsCommand {
+ /// Clear the directory stack.
+ #[arg(short = 'c')]
+ clear: bool,
+
+ /// Don't tilde-shorten paths.
+ #[arg(short = 'l')]
+ tilde_long: bool,
+
+ /// Print one directory per line instead of all on one line.
+ #[arg(short = 'p')]
+ print_one_per_line: bool,
+
+ /// Print one directory per line with its index.
+ #[arg(short = 'v')]
+ print_one_per_line_with_index: bool,
+ //
+ // TODO(dirs): implement +N and -N
+}
+
+impl builtins::Command for DirsCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.clear {
+ context.shell.directory_stack_mut().clear();
+ } else {
+ let dirs = vec![context.shell.working_dir()]
+ .into_iter()
+ .chain(
+ context
+ .shell
+ .directory_stack()
+ .iter()
+ .rev()
+ .map(|p| p.as_path()),
+ )
+ .collect::>();
+
+ let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index;
+
+ for (i, dir) in dirs.iter().enumerate() {
+ if !one_per_line && i > 0 {
+ write!(context.stdout(), " ")?;
+ }
+
+ if self.print_one_per_line_with_index {
+ write!(context.stdout(), "{i:2} ")?;
+ }
+
+ let mut dir_str = dir.to_string_lossy().to_string();
+
+ if !self.tilde_long {
+ dir_str = context.shell.tilde_shorten(dir_str);
+ }
+
+ write!(context.stdout(), "{dir_str}")?;
+
+ if one_per_line || i == dirs.len() - 1 {
+ writeln!(context.stdout())?;
+ }
+ }
+
+ return Ok(ExecutionResult::success());
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/dot.rs b/local/recipes/shells/brush/source/brush-builtins/src/dot.rs
new file mode 100644
index 0000000000..7bf5a4b59d
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/dot.rs
@@ -0,0 +1,34 @@
+use std::path::Path;
+
+use brush_core::builtins;
+use clap::Parser;
+
+/// Evaluate the provided script in the current shell environment.
+#[derive(Parser)]
+pub(crate) struct DotCommand {
+ /// Path to the script to evaluate.
+ script_path: String,
+
+ /// Any arguments to be passed as positional parameters to the script.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ script_args: Vec,
+}
+
+impl builtins::Command for DotCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // TODO(dot): Handle trap inheritance.
+ context
+ .shell
+ .source_script(
+ Path::new(&self.script_path),
+ self.script_args.iter(),
+ &context.params,
+ )
+ .await
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/echo.rs b/local/recipes/shells/brush/source/brush-builtins/src/echo.rs
new file mode 100644
index 0000000000..03da65a5a8
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/echo.rs
@@ -0,0 +1,81 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins, escape};
+
+/// Echo text to standard output.
+#[derive(Parser)]
+#[clap(disable_help_flag = true, disable_version_flag = true)]
+pub(crate) struct EchoCommand {
+ /// Suppress the trailing newline from the output.
+ #[arg(short = 'n')]
+ no_trailing_newline: bool,
+
+ /// Interpret backslash escapes in the provided text.
+ #[arg(short = 'e')]
+ interpret_backslash_escapes: bool,
+
+ /// Do not interpret backslash escapes in the provided text.
+ #[arg(short = 'E')]
+ no_interpret_backslash_escapes: bool,
+
+ /// Tokens to echo to standard output.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for EchoCommand {
+ type Error = brush_core::Error;
+
+ /// Override the default [`builtins::Command::new`] function to handle clap's limitation related
+ /// to `--`. See [`builtins::parse_known`] for more information
+ /// TODO(echo): we can safely remove this after the issue is resolved
+ fn new(args: I) -> Result
+ where
+ I: IntoIterator,
+ {
+ let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?;
+ if let Some(args) = rest_args {
+ this.args.extend(args);
+ }
+ Ok(this)
+ }
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut trailing_newline = !self.no_trailing_newline;
+ let mut s;
+ if self.interpret_backslash_escapes {
+ s = String::new();
+ for (i, arg) in self.args.iter().enumerate() {
+ if i > 0 {
+ s.push(' ');
+ }
+
+ let (expanded_arg, keep_going) = escape::expand_backslash_escapes(
+ arg.as_str(),
+ escape::EscapeExpansionMode::EchoBuiltin,
+ )?;
+ s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice()));
+
+ if !keep_going {
+ trailing_newline = false;
+ break;
+ }
+ }
+ } else {
+ s = self.args.join(" ");
+ }
+
+ if trailing_newline {
+ s.push('\n');
+ }
+
+ write!(context.stdout(), "{s}")?;
+ context.stdout().flush()?;
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/enable.rs b/local/recipes/shells/brush/source/brush-builtins/src/enable.rs
new file mode 100644
index 0000000000..48dc7d14fd
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/enable.rs
@@ -0,0 +1,96 @@
+use brush_core::ExecutionResult;
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+use brush_core::builtins;
+use brush_core::error;
+
+/// Enable, disable, or display built-in commands.
+#[derive(Parser)]
+pub(crate) struct EnableCommand {
+ /// Print a list of built-in commands.
+ #[arg(short = 'a')]
+ print_list: bool,
+
+ /// Disables the specified built-in commands.
+ #[arg(short = 'n')]
+ disable: bool,
+
+ /// Print a list of built-in commands with reusable output.
+ #[arg(short = 'p')]
+ print_reusably: bool,
+
+ /// Only operate on special built-in commands.
+ #[arg(short = 's')]
+ special_only: bool,
+
+ /// Path to a shared object from which built-in commands will be loaded.
+ #[arg(short = 'f', value_name = "PATH")]
+ shared_object_path: Option,
+
+ /// Remove the built-in commands loaded from the indicated object path.
+ #[arg(short = 'd')]
+ remove_loaded_builtin: bool,
+
+ /// Names of built-in commands to operate on.
+ names: Vec,
+}
+
+impl builtins::Command for EnableCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ if self.shared_object_path.is_some() {
+ return error::unimp("enable -f");
+ }
+ if self.remove_loaded_builtin {
+ return error::unimp("enable -d");
+ }
+
+ if !self.names.is_empty() {
+ for name in &self.names {
+ if let Some(builtin) = context.shell.builtin_mut(name) {
+ builtin.disabled = self.disable;
+ } else {
+ writeln!(context.stderr(), "{name}: not a shell builtin")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else {
+ let builtins: Vec<_> = context
+ .shell
+ .builtins()
+ .iter()
+ .sorted_by_key(|(name, _reg)| *name)
+ .collect();
+
+ for (builtin_name, builtin) in builtins {
+ if self.disable {
+ if !builtin.disabled {
+ continue;
+ }
+ } else if self.print_list {
+ if builtin.disabled {
+ continue;
+ }
+ }
+
+ if self.special_only && !builtin.special_builtin {
+ continue;
+ }
+
+ let prefix = if builtin.disabled { "-n " } else { "" };
+
+ writeln!(context.stdout(), "enable {prefix}{builtin_name}")?;
+ }
+ }
+
+ Ok(result)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/eval.rs b/local/recipes/shells/brush/source/brush-builtins/src/eval.rs
new file mode 100644
index 0000000000..0526fc965a
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/eval.rs
@@ -0,0 +1,42 @@
+use brush_core::{ExecutionResult, builtins};
+use clap::Parser;
+
+/// Evaluate the given string as script.
+#[derive(Parser)]
+pub(crate) struct EvalCommand {
+ /// The script to evaluate.
+ #[clap(allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for EvalCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if !self.args.is_empty() {
+ let args_concatenated = self.args.join(" ");
+
+ tracing::debug!("Applying eval to: {:?}", args_concatenated);
+
+ // Our new source context is relative to the current position because we are only
+ // providing the raw string being eval'd.
+ // TODO(source-info): Provide the location of the specific tokens that make up
+ // `self.args`.
+ let source_info = context.shell.call_stack().current_pos_as_source_info();
+
+ // Return the direct result of running the string; we intentionally
+ // pass through the result and honor its requested control flow. eval
+ // executes in the current environment, so all control flow (return,
+ // exit, break, continue) should propagate.
+ context
+ .shell
+ .run_string(args_concatenated, &source_info, &context.params)
+ .await
+ } else {
+ Ok(ExecutionResult::success())
+ }
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/exec.rs b/local/recipes/shells/brush/source/brush-builtins/src/exec.rs
new file mode 100644
index 0000000000..d3ade049d6
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/exec.rs
@@ -0,0 +1,83 @@
+use clap::Parser;
+use std::{borrow::Cow, os::unix::process::CommandExt};
+
+use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
+
+/// Exec the provided command.
+#[derive(Parser)]
+pub(crate) struct ExecCommand {
+ /// Pass given name as zeroth argument to command.
+ #[arg(short = 'a', value_name = "NAME")]
+ name_for_argv0: Option,
+
+ /// Exec command with an empty environment.
+ #[arg(short = 'c')]
+ empty_environment: bool,
+
+ /// Exec command as a login shell.
+ #[arg(short = 'l')]
+ exec_as_login: bool,
+
+ /// Command and args.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for ExecCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.args.is_empty() {
+ // When no arguments are present, then there's nothing for us to execute -- but we need
+ // to ensure that any redirections setup for this builtin get applied to the calling
+ // shell instance.
+ #[allow(clippy::needless_collect)]
+ let fds: Vec<_> = context.iter_fds().collect();
+
+ context.shell.replace_open_files(fds.into_iter());
+ return Ok(ExecutionResult::success());
+ }
+
+ // If we know we're already running in a subshell, then `exec`ing is actually
+ // unsafe, since it would also replace the *parent* shell instance. We instead
+ // delegate to the `command` builtin to perform the execution, with an expectation
+ // of returning.
+ if context.shell.is_subshell() {
+ if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
+ return brush_core::error::unimp("exec with options in subshell not yet supported");
+ }
+
+ let cmd_cmd = crate::command::CommandCommand {
+ command_and_args: self.args.clone(),
+ ..Default::default()
+ };
+
+ return cmd_cmd.execute(context).await;
+ }
+
+ let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
+
+ if self.exec_as_login {
+ argv0 = Cow::Owned(std::format!("-{argv0}"));
+ }
+
+ let mut cmd = commands::compose_std_command(
+ &context,
+ &self.args[0],
+ argv0.as_str(),
+ &self.args[1..],
+ self.empty_environment,
+ )?;
+
+ let exec_error = cmd.exec();
+
+ if exec_error.kind() == std::io::ErrorKind::NotFound {
+ Ok(ExecutionExitCode::NotFound.into())
+ } else {
+ Err(ErrorKind::from(exec_error).into())
+ }
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/exit.rs b/local/recipes/shells/brush/source/brush-builtins/src/exit.rs
new file mode 100644
index 0000000000..31786f3c8b
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/exit.rs
@@ -0,0 +1,32 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
+
+/// Exit the shell.
+#[derive(Parser)]
+pub(crate) struct ExitCommand {
+ /// The exit code to return.
+ #[arg(allow_hyphen_values = true)]
+ code: Option,
+}
+
+impl builtins::Command for ExitCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ #[expect(clippy::cast_sign_loss)]
+ let code_8bit = if let Some(code_32bit) = &self.code {
+ (code_32bit & 0xFF) as u8
+ } else {
+ context.shell.last_exit_status()
+ };
+
+ let mut result = ExecutionResult::new(code_8bit);
+ result.next_control_flow = ExecutionControlFlow::ExitShell;
+
+ Ok(result)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/export.rs b/local/recipes/shells/brush/source/brush-builtins/src/export.rs
new file mode 100644
index 0000000000..5e906bd2bf
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/export.rs
@@ -0,0 +1,174 @@
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+use brush_core::{
+ ExecutionExitCode, ExecutionResult, builtins,
+ env::{EnvironmentLookup, EnvironmentScope},
+ parser::ast,
+ variables,
+};
+
+/// Add or update exported shell variables.
+#[derive(Parser)]
+pub(crate) struct ExportCommand {
+ /// Names are treated as function names.
+ #[arg(short = 'f')]
+ names_are_functions: bool,
+
+ /// Un-export the names.
+ #[arg(short = 'n')]
+ unexport: bool,
+
+ /// Display all exported names.
+ #[arg(short = 'p')]
+ display_exported_names: bool,
+
+ //
+ // Declarations
+ //
+ // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
+ #[clap(skip)]
+ declarations: Vec,
+}
+
+impl builtins::DeclarationCommand for ExportCommand {
+ fn set_declarations(&mut self, declarations: Vec) {
+ self.declarations = declarations;
+ }
+}
+
+impl builtins::Command for ExportCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.declarations.is_empty() {
+ display_all_exported_vars(&context)?;
+ return Ok(ExecutionResult::success());
+ }
+
+ let mut result = ExecutionResult::success();
+ for decl in &self.declarations {
+ let current_result = self.process_decl(&mut context, decl)?;
+ if !current_result.is_success() {
+ result = current_result;
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl ExportCommand {
+ fn process_decl(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ decl: &brush_core::CommandArg,
+ ) -> Result {
+ match decl {
+ brush_core::CommandArg::String(s) => {
+ // See if this is supposed to be a function name.
+ if self.names_are_functions {
+ // Try to find the function already present; if we find it, then mark it
+ // exported.
+ if let Some(func) = context.shell.func_mut(s) {
+ if self.unexport {
+ func.unexport();
+ } else {
+ func.export();
+ }
+ } else {
+ writeln!(context.stderr(), "{s}: not a function")?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+ }
+ // Try to find the variable already present; if we find it, then mark it
+ // exported.
+ else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) {
+ if self.unexport {
+ variable.unexport();
+ } else {
+ variable.export();
+ }
+ }
+ }
+ brush_core::CommandArg::Assignment(assignment) => {
+ let name = match &assignment.name {
+ ast::AssignmentName::VariableName(name) => name,
+ ast::AssignmentName::ArrayElementName(_, _) => {
+ writeln!(context.stderr(), "not a valid variable name")?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+ };
+
+ let value = match &assignment.value {
+ ast::AssignmentValue::Scalar(s) => {
+ variables::ShellValueLiteral::Scalar(s.flatten())
+ }
+ ast::AssignmentValue::Array(a) => {
+ variables::ShellValueLiteral::Array(variables::ArrayLiteral(
+ a.iter()
+ .map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten()))
+ .collect(),
+ ))
+ }
+ };
+
+ // `export name+=value` appends to the existing value, exactly like a
+ // bare `name+=value`. update_or_add always replaces, so when the
+ // variable already exists honor the append here. A missing variable
+ // falls through: appending to nothing is a plain assignment.
+ if assignment.append
+ && let Some((_, variable)) = context.shell.env_mut().get_mut(name)
+ {
+ variable.assign(value, true)?;
+ if self.unexport {
+ variable.unexport();
+ } else {
+ variable.export();
+ }
+ return Ok(ExecutionResult::success());
+ }
+
+ // Update the variable with the provided value and then mark it exported.
+ context.shell.env_mut().update_or_add(
+ name,
+ value,
+ |var| {
+ if self.unexport {
+ var.unexport();
+ } else {
+ var.export();
+ }
+ Ok(())
+ },
+ EnvironmentLookup::Anywhere,
+ EnvironmentScope::Global,
+ )?;
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+fn display_all_exported_vars(
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+) -> Result<(), brush_core::Error> {
+ // Enumerate variables, sorted by key.
+ for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) {
+ if variable.is_exported() {
+ let value = variable.value().try_get_cow_str(context.shell);
+ if let Some(value) = value {
+ writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?;
+ } else {
+ writeln!(context.stdout(), "declare -x {name}")?;
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/factory.rs b/local/recipes/shells/brush/source/brush-builtins/src/factory.rs
new file mode 100644
index 0000000000..99eeb8e44e
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/factory.rs
@@ -0,0 +1,229 @@
+use std::collections::HashMap;
+
+#[allow(clippy::wildcard_imports)]
+use super::*;
+
+#[allow(unused_imports, reason = "not all builtins are used in all configs")]
+use brush_core::builtins::{self, builtin, decl_builtin, raw_arg_builtin, simple_builtin};
+
+/// Identifies well-known sets of builtins.
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub enum BuiltinSet {
+ /// Identifies builtins appropriate for POSIX `sh` compatibility.
+ ShMode,
+ /// Identifies builtins appropriate for a more full-featured `bash`-compatible shell.
+ BashMode,
+}
+
+/// Returns the default set of built-in commands.
+///
+/// # Arguments
+///
+/// * `set` - The set of built-ins to return.
+#[allow(clippy::too_many_lines)]
+pub fn default_builtins(
+ set: BuiltinSet,
+) -> HashMap> {
+ let mut m = HashMap::>::new();
+
+ //
+ // POSIX special builtins
+ //
+ // N.B. There seems to be some inconsistency as to whether 'times'
+ // should be a special built-in.
+ //
+
+ #[cfg(feature = "builtin.break")]
+ m.insert(
+ "break".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.colon")]
+ m.insert(
+ ":".into(),
+ simple_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.continue")]
+ m.insert(
+ "continue".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.dot")]
+ m.insert(".".into(), builtin::().special());
+ #[cfg(feature = "builtin.eval")]
+ m.insert("eval".into(), builtin::().special());
+ #[cfg(all(feature = "builtin.exec", unix))]
+ m.insert("exec".into(), builtin::().special());
+ #[cfg(feature = "builtin.exit")]
+ m.insert("exit".into(), builtin::().special());
+ #[cfg(feature = "builtin.export")]
+ m.insert(
+ "export".into(),
+ decl_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.return")]
+ m.insert(
+ "return".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.set")]
+ m.insert("set".into(), builtin::().special());
+ #[cfg(feature = "builtin.shift")]
+ m.insert(
+ "shift".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.trap")]
+ m.insert("trap".into(), builtin::().special());
+ #[cfg(feature = "builtin.unset")]
+ m.insert(
+ "unset".into(),
+ builtin::().special(),
+ );
+
+ #[cfg(feature = "builtin.declare")]
+ m.insert(
+ "readonly".into(),
+ decl_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.times")]
+ m.insert(
+ "times".into(),
+ builtin::().special(),
+ );
+
+ //
+ // Non-special builtins
+ //
+
+ #[cfg(feature = "builtin.alias")]
+ m.insert("alias".into(), builtin::()); // TODO(alias): should be exec_declaration_builtin
+ #[cfg(feature = "builtin.bg")]
+ m.insert("bg".into(), builtin::());
+ #[cfg(feature = "builtin.cd")]
+ m.insert("cd".into(), builtin::());
+ #[cfg(feature = "builtin.command")]
+ m.insert("command".into(), builtin::());
+ #[cfg(feature = "builtin.false")]
+ m.insert("false".into(), simple_builtin::());
+ #[cfg(feature = "builtin.fg")]
+ m.insert("fg".into(), builtin::());
+ #[cfg(feature = "builtin.getopts")]
+ m.insert("getopts".into(), builtin::());
+ #[cfg(feature = "builtin.hash")]
+ m.insert("hash".into(), builtin::());
+ #[cfg(feature = "builtin.help")]
+ m.insert("help".into(), builtin::());
+ #[cfg(feature = "builtin.jobs")]
+ m.insert("jobs".into(), builtin::());
+ #[cfg(all(feature = "builtin.kill", unix))]
+ m.insert("kill".into(), builtin::());
+ #[cfg(feature = "builtin.declare")]
+ m.insert(
+ "local".into(),
+ decl_builtin::(),
+ );
+ #[cfg(feature = "builtin.pwd")]
+ m.insert("pwd".into(), builtin::());
+ #[cfg(feature = "builtin.read")]
+ m.insert("read".into(), builtin::());
+ #[cfg(feature = "builtin.true")]
+ m.insert("true".into(), simple_builtin::());
+ #[cfg(feature = "builtin.type")]
+ m.insert("type".into(), builtin::());
+ #[cfg(all(feature = "builtin.ulimit", unix))]
+ m.insert("ulimit".into(), builtin::());
+ #[cfg(all(feature = "builtin.umask", unix))]
+ m.insert("umask".into(), builtin::());
+ #[cfg(feature = "builtin.unalias")]
+ m.insert("unalias".into(), builtin::());
+ #[cfg(feature = "builtin.wait")]
+ m.insert("wait".into(), builtin::());
+
+ #[cfg(feature = "builtin.fc")]
+ m.insert("fc".into(), builtin::());
+
+ if matches!(set, BuiltinSet::BashMode) {
+ #[cfg(feature = "builtin.builtin")]
+ m.insert(
+ "builtin".into(),
+ raw_arg_builtin::(),
+ );
+ #[cfg(feature = "builtin.declare")]
+ m.insert(
+ "declare".into(),
+ decl_builtin::(),
+ );
+ #[cfg(feature = "builtin.echo")]
+ m.insert("echo".into(), builtin::());
+ #[cfg(feature = "builtin.enable")]
+ m.insert("enable".into(), builtin::());
+ #[cfg(feature = "builtin.let")]
+ m.insert("let".into(), builtin::());
+ #[cfg(feature = "builtin.mapfile")]
+ m.insert("mapfile".into(), builtin::());
+ #[cfg(feature = "builtin.mapfile")]
+ m.insert("readarray".into(), builtin::());
+ #[cfg(all(feature = "builtin.printf", any(unix, windows)))]
+ m.insert("printf".into(), builtin::());
+ #[cfg(feature = "builtin.shopt")]
+ m.insert("shopt".into(), builtin::());
+ #[cfg(feature = "builtin.dot")]
+ m.insert("source".into(), builtin::().special());
+ #[cfg(all(feature = "builtin.suspend", unix))]
+ m.insert("suspend".into(), builtin::());
+ #[cfg(feature = "builtin.test")]
+ m.insert("test".into(), builtin::());
+ #[cfg(feature = "builtin.test")]
+ m.insert("[".into(), builtin::());
+ #[cfg(feature = "builtin.declare")]
+ m.insert(
+ "typeset".into(),
+ decl_builtin::(),
+ );
+
+ // Completion builtins
+ #[cfg(feature = "builtin.complete")]
+ m.insert(
+ "complete".into(),
+ builtin::(),
+ );
+ #[cfg(feature = "builtin.compgen")]
+ m.insert("compgen".into(), builtin::());
+ #[cfg(feature = "builtin.compopt")]
+ m.insert("compopt".into(), builtin::());
+
+ // Dir stack builtins
+ #[cfg(feature = "builtin.dirs")]
+ m.insert("dirs".into(), builtin::());
+ #[cfg(feature = "builtin.popd")]
+ m.insert("popd".into(), builtin::());
+ #[cfg(feature = "builtin.pushd")]
+ m.insert("pushd".into(), builtin::());
+
+ // Input configuration builtins
+ #[cfg(feature = "builtin.bind")]
+ m.insert("bind".into(), builtin::());
+
+ // History
+ #[cfg(feature = "builtin.history")]
+ m.insert("history".into(), builtin::());
+
+ #[cfg(feature = "builtin.caller")]
+ m.insert("caller".into(), builtin::());
+
+ // TODO(disown): implement disown builtin
+ m.insert(
+ "disown".into(),
+ builtin::(),
+ );
+
+ // TODO(logout): implement logout builtin
+ m.insert(
+ "logout".into(),
+ builtin::(),
+ );
+ }
+
+ m
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/false_.rs b/local/recipes/shells/brush/source/brush-builtins/src/false_.rs
new file mode 100644
index 0000000000..25fa678c7b
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/false_.rs
@@ -0,0 +1,26 @@
+use brush_core::{ExecutionResult, builtins, error};
+
+/// Return exit code 1.
+pub(crate) struct FalseCommand {}
+
+impl builtins::SimpleCommand for FalseCommand {
+ fn get_content(
+ _name: &str,
+ content_type: builtins::ContentType,
+ _options: &builtins::ContentOptions,
+ ) -> Result {
+ match content_type {
+ builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
+ builtins::ContentType::ShortUsage => Ok("false".into()),
+ builtins::ContentType::ShortDescription => Ok("false - fail".into()),
+ builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
+ }
+ }
+
+ fn execute, S: AsRef>(
+ _context: brush_core::ExecutionContext<'_, SE>,
+ _args: I,
+ ) -> Result {
+ Ok(ExecutionResult::general_error())
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/fc.rs b/local/recipes/shells/brush/source/brush-builtins/src/fc.rs
new file mode 100644
index 0000000000..9f4695efdd
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/fc.rs
@@ -0,0 +1,301 @@
+use brush_core::{ExecutionResult, builtins, error, history};
+use clap::Parser;
+use std::io::Write;
+
+/// Process command history list.
+#[derive(Parser)]
+pub(crate) struct FcCommand {
+ /// List commands instead of editing them.
+ #[arg(short = 'l')]
+ list: bool,
+
+ /// Suppress line numbers when listing.
+ #[arg(short = 'n', requires = "list")]
+ no_line_numbers: bool,
+
+ /// Reverse the order of commands.
+ #[arg(short = 'r')]
+ reverse: bool,
+
+ /// Re-execute command after substitution (old=new format).
+ #[arg(short = 's')]
+ substitute: bool,
+
+ /// Editor to use (only relevant when not listing or substituting).
+ #[arg(short = 'e', value_name = "ENAME")]
+ editor: Option,
+
+ /// First command in range (number or string prefix).
+ #[arg(value_name = "FIRST", allow_hyphen_values = true)]
+ first: Option,
+
+ /// Last command in range (number or string prefix).
+ #[arg(value_name = "LAST", allow_hyphen_values = true)]
+ last: Option,
+}
+
+impl builtins::Command for FcCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.substitute {
+ return self.do_execute(context).await;
+ }
+
+ if self.list {
+ return self.do_list(&context);
+ }
+
+ error::unimp("fc editor mode is not yet implemented")
+ }
+}
+
+impl FcCommand {
+ fn do_list(
+ &self,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result {
+ let history = context
+ .shell
+ .history()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+
+ let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
+
+ // Determine the order of iteration
+ let indices: Vec = if reverse {
+ (first_idx..=last_idx).rev().collect()
+ } else {
+ (first_idx..=last_idx).collect()
+ };
+
+ for idx in indices {
+ if let Some(item) = history.get(idx) {
+ if self.no_line_numbers {
+ // With -n, bash still outputs a tab before the command
+ writeln!(context.stdout(), "\t {}", item.command_line)?;
+ } else {
+ // Match bash's fc format: number, tab, command
+ writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?;
+ }
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+
+ async fn do_execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result {
+ let history = context
+ .shell
+ .history()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+
+ // Parse the first argument for pattern=replacement
+ let (pattern, replacement) = self
+ .first
+ .as_ref()
+ .and_then(|s| s.split_once('='))
+ .map_or((None, None), |(p, r)| (Some(p), Some(r)));
+
+ // Determine which command to re-execute
+ let cmd_spec = if pattern.is_some() {
+ // If we have a pattern, the command spec is in 'last' if present
+ self.last.as_deref()
+ } else {
+ // Otherwise, it's in 'first'
+ self.first.as_deref()
+ };
+
+ // Find the command
+ let cmd_line = if let Some(spec) = cmd_spec {
+ Self::find_command_by_specifier(history, spec)?
+ } else {
+ // No spec means use the previous command (excluding the fc command itself)
+ let effective_count = effective_history_count(history);
+ history
+ .get(effective_count.saturating_sub(1))
+ .map(|item| item.command_line.clone())
+ .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?
+ };
+
+ // Apply substitution if present
+ let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) {
+ cmd_line.replace(pat, rep)
+ } else {
+ cmd_line
+ };
+
+ // Echo the command to stderr.
+ writeln!(context.stderr(), "{final_cmd}")?;
+
+ // Remove the fc command from history before executing the substituted command
+ // This matches bash behavior where the fc command is replaced by the executed command
+ let history_mut = context
+ .shell
+ .history_mut()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+ history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
+
+ let source_info = brush_core::SourceInfo::from("(history)");
+
+ // Execute the command
+ let result = context
+ .shell
+ .run_string(final_cmd.clone(), &source_info, &context.params)
+ .await?;
+
+ // Add the executed command to history.
+ context.shell.add_to_history(&final_cmd)?;
+
+ Ok(result)
+ }
+
+ fn resolve_range(
+ &self,
+ history: &history::History,
+ ) -> Result<(usize, usize, bool), brush_core::Error> {
+ let effective_count = effective_history_count(history);
+ let max_idx = effective_count.saturating_sub(1);
+
+ // Resolve first index
+ let first_idx = self
+ .first
+ .as_ref()
+ .map(|s| Self::resolve_position(history, s))
+ .transpose()?
+ .unwrap_or_else(|| {
+ if self.list {
+ effective_count.saturating_sub(16) // Default for listing: -16
+ } else {
+ max_idx // Default for editing: previous command
+ }
+ });
+
+ // Resolve last index (default depends on mode and first_idx)
+ let default_last = if self.list { max_idx } else { first_idx };
+ let last_idx = self
+ .last
+ .as_ref()
+ .map(|s| Self::resolve_position(history, s))
+ .transpose()?
+ .unwrap_or(default_last);
+
+ // If first > last, swap them and indicate reversal
+ let (first_idx, last_idx, force_reverse) = if first_idx > last_idx {
+ (last_idx, first_idx, true)
+ } else {
+ (first_idx, last_idx, false)
+ };
+
+ // Clamp both indices to valid range
+ Ok((
+ first_idx.min(max_idx),
+ last_idx.min(max_idx),
+ force_reverse || self.reverse,
+ ))
+ }
+
+ /// Resolves a position specifier (number or string prefix) to a history index.
+ /// NOTE: The returned index may still be out of range if the history is empty.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to resolve against.
+ /// * `spec` - The position specifier (number or string prefix).
+ fn resolve_position(
+ history: &history::History,
+ spec: &str,
+ ) -> Result {
+ // Try to parse it as a number. If it's not parseable, then we need to assume
+ // it's a string prefix we need to search for.
+ let Ok(num) = spec.parse::() else {
+ // Not a number, treat as string prefix
+ return Self::find_command_by_prefix(history, spec);
+ };
+
+ let effective_count = effective_history_count(history);
+
+ #[expect(clippy::cast_sign_loss)]
+ #[expect(clippy::cast_possible_truncation)]
+ let result = match num.cmp(&0) {
+ std::cmp::Ordering::Equal => {
+ // 0 means -1 for listing (relative to effective count)
+ effective_count.saturating_sub(1)
+ }
+ std::cmp::Ordering::Greater => {
+ // Positive: 1-based index
+ let idx = (num - 1) as usize;
+ if idx < effective_count {
+ idx
+ } else {
+ // Out of range - use 0 (first item)
+ 0
+ }
+ }
+ std::cmp::Ordering::Less => {
+ // Negative: offset from end (relative to effective count)
+ let offset = (-num) as usize;
+ effective_count.saturating_sub(offset)
+ }
+ };
+
+ Ok(result)
+ }
+
+ /// Finds the command matching the given specifier (number or string prefix). Returns
+ /// the command line. Returns an error if no such command can be found in the history.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to search.
+ /// * `spec` - The position spec
+ fn find_command_by_specifier(
+ history: &history::History,
+ spec: &str,
+ ) -> Result {
+ let idx = Self::resolve_position(history, spec)?;
+ history
+ .get(idx)
+ .map(|item| item.command_line.clone())
+ .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))
+ }
+
+ /// Finds the most recent command starting with the given prefix. Returns
+ /// the index of the command in the history. Returns an error if no such
+ /// command can be found in the history.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to search.
+ /// * `prefix` - The command prefix to search for.
+ fn find_command_by_prefix(
+ history: &history::History,
+ prefix: &str,
+ ) -> Result {
+ // Search backwards for a command starting with the prefix (excluding fc command itself)
+ let effective_count = effective_history_count(history);
+
+ for idx in (0..effective_count).rev() {
+ if let Some(item) = history.get(idx) {
+ if item.command_line.starts_with(prefix) {
+ return Ok(idx);
+ }
+ }
+ }
+
+ Err(brush_core::Error::from(
+ error::ErrorKind::HistoryItemNotFound,
+ ))
+ }
+}
+
+/// Returns the effective history count (excluding the fc command itself).
+fn effective_history_count(history: &history::History) -> usize {
+ history.count().saturating_sub(1)
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/fg.rs b/local/recipes/shells/brush/source/brush-builtins/src/fg.rs
new file mode 100644
index 0000000000..ec5796fe3e
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/fg.rs
@@ -0,0 +1,73 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins, jobs, sys};
+
+/// Move a specified job to the foreground.
+#[derive(Parser)]
+pub(crate) struct FgCommand {
+ /// Job spec for the job to move to the foreground; if not specified, the current job is moved.
+ job_spec: Option,
+}
+
+impl builtins::Command for FgCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut stderr = context.stdout();
+
+ // Read interactive option before taking mutable borrow on jobs
+ let is_interactive = context.shell.options().interactive;
+
+ if let Some(job_spec) = &self.job_spec {
+ if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
+ job.move_to_foreground()?;
+ writeln!(stderr, "{}", job.command_line)?;
+
+ let result = job.wait().await?;
+ if is_interactive {
+ sys::terminal::move_self_to_foreground()?;
+ }
+
+ if matches!(job.state, jobs::JobState::Stopped) {
+ // N.B. We use the '\r' to overwrite any ^Z output.
+ let formatted = job.to_string();
+ writeln!(context.stderr(), "\r{formatted}")?;
+ }
+
+ Ok(result)
+ } else {
+ writeln!(
+ stderr,
+ "{}: {}: no such job",
+ job_spec, context.command_name
+ )?;
+ Ok(ExecutionResult::general_error())
+ }
+ } else {
+ if let Some(job) = context.shell.jobs_mut().current_job_mut() {
+ job.move_to_foreground()?;
+ writeln!(stderr, "{}", job.command_line)?;
+
+ let result = job.wait().await?;
+ if is_interactive {
+ sys::terminal::move_self_to_foreground()?;
+ }
+
+ if matches!(job.state, jobs::JobState::Stopped) {
+ // N.B. We use the '\r' to overwrite any ^Z output.
+ let formatted = job.to_string();
+ writeln!(context.stderr(), "\r{formatted}")?;
+ }
+
+ Ok(result)
+ } else {
+ writeln!(stderr, "{}: no current job", context.command_name)?;
+ Ok(ExecutionResult::general_error())
+ }
+ }
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs b/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs
new file mode 100644
index 0000000000..e57247fa52
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs
@@ -0,0 +1,402 @@
+use std::{collections::HashMap, io::Write};
+
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins, env, variables};
+
+/// Parse command options.
+#[derive(Parser)]
+pub(crate) struct GetOptsCommand {
+ /// Specification for options
+ options_string: String,
+
+ /// Name of variable to receive next option
+ variable_name: String,
+
+ /// Arguments to parse
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+// We track cross-call state in special variables. They are hidden from enumeration
+// (e.g. `set`, `declare`) so they don't leak into scripts' environments.
+const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
+const VAR_GETOPTS_LAST_OPTIND: &str = "__GETOPTS_LAST_OPTIND";
+const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
+
+/// The result of processing one option from the argument list.
+struct GetOptsResult {
+ /// The value to assign to the target variable (the option char, `?`, or `:`).
+ variable_value: String,
+ /// The value for OPTARG, if any (option's argument or, on error, the offending char).
+ optarg: Option,
+ /// The new value for OPTIND after this call.
+ optind: usize,
+ /// Exit code: success (0) if an option was found, general error (1) when done.
+ exit_code: ExecutionResult,
+}
+
+/// Parsed representation of the optstring (e.g., `":a:bc"`).
+struct OptionSpec {
+ /// Maps each option character to whether it requires an argument.
+ defs: HashMap,
+ /// True when the optstring has a leading `:`, suppressing error messages
+ /// and reporting errors via `?`/`:` in the variable and OPTARG instead.
+ silent_errors: bool,
+}
+
+/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
+/// mode. Each letter defines an option; a `:` immediately after a letter means it
+/// takes an argument. Duplicate letters are ignored (first definition wins).
+fn parse_option_spec(spec: &str) -> OptionSpec {
+ let mut defs = HashMap::::new();
+ let mut silent_errors = false;
+
+ let mut last_char = None;
+ for c in spec.chars() {
+ if c == ':' {
+ if let Some(last_char) = last_char {
+ defs.insert(last_char, true);
+ } else if defs.is_empty() {
+ // First character is ':' — request silent error reporting.
+ silent_errors = true;
+ }
+ continue;
+ }
+
+ if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
+ e.insert(false);
+ last_char = Some(c);
+ } else {
+ // Duplicate option char; first definition wins.
+ // Clear last_char so a trailing colon doesn't modify the first definition.
+ last_char = None;
+ }
+ }
+
+ OptionSpec {
+ defs,
+ silent_errors,
+ }
+}
+
+impl builtins::Command for GetOptsCommand {
+ type Error = brush_core::Error;
+
+ /// Override the default [`builtins::Command::new`] function to handle clap's limitation related
+ /// to `--`. See [`builtins::parse_known`] for more information
+ /// TODO(command): we can safely remove this after the issue is resolved
+ fn new(args: I) -> Result
+ where
+ I: IntoIterator,
+ {
+ let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?;
+ if let Some(args) = rest_args {
+ this.args.extend(args);
+ }
+ Ok(this)
+ }
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ // Validate the target variable name.
+ if !env::valid_variable_name(&self.variable_name) {
+ writeln!(
+ context.stderr(),
+ "{}: `{}': not a valid identifier",
+ context.command_name,
+ self.variable_name
+ )?;
+ return Ok(ExecutionResult::new(1));
+ }
+
+ let spec = parse_option_spec(&self.options_string);
+
+ // If unset or non-numeric, assume OPTIND is 1.
+ let next_index_signed = context
+ .shell
+ .env_str("OPTIND")
+ .and_then(|s| brush_core::int_utils::parse::(s.as_ref(), 10).ok())
+ .unwrap_or(1);
+
+ // Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
+ // parsing). If the current OPTIND differs from what we last set, clear
+ // the internal char index so we don't resume mid-arg.
+ let last_optind = context
+ .shell
+ .env_str(VAR_GETOPTS_LAST_OPTIND)
+ .and_then(|s| s.parse::().ok());
+ if last_optind != Some(next_index_signed) {
+ context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
+ }
+
+ #[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive
+ let next_index = next_index_signed.max(1) as usize;
+
+ // Select the arguments to parse. If none were explicitly provided, we
+ // default to using the shell's current positional parameters.
+ // Clone positional params to avoid borrowing context immutably while we
+ // also need it mutably in parse_next_option.
+ let owned_args;
+ let args_to_parse = if !self.args.is_empty() {
+ &self.args
+ } else {
+ owned_args = context.shell.current_shell_args().to_vec();
+ &owned_args
+ };
+
+ let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?;
+
+ update_variables(&mut context, &self.variable_name, result)
+ }
+}
+
+/// Extracts the next option from `args_to_parse` starting at 1-based position
+/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both
+/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or
+/// missing arguments. Tracks position within combined flags via the
+/// `__GETOPTS_NEXT_CHAR` shell variable.
+fn parse_next_option(
+ context: &mut brush_core::ExecutionContext<'_, SE>,
+ spec: &OptionSpec,
+ args_to_parse: &[String],
+ mut next_index: usize,
+) -> Result {
+ // See if there are any args left to parse.
+ if next_index > args_to_parse.len() {
+ return Ok(GetOptsResult {
+ variable_value: String::from("?"),
+ optarg: None,
+ // Normalize OPTIND to one past the last argument.
+ optind: args_to_parse.len() + 1,
+ exit_code: ExecutionResult::general_error(),
+ });
+ }
+
+ let arg = args_to_parse[next_index - 1].as_str();
+
+ // See if this is an option.
+ if !arg.starts_with('-') || arg == "--" || arg == "-" {
+ // Not an option. If it was "--", skip past it.
+ return Ok(GetOptsResult {
+ variable_value: String::from("?"),
+ optarg: None,
+ optind: if arg == "--" {
+ next_index + 1
+ } else {
+ next_index
+ },
+ exit_code: ExecutionResult::general_error(),
+ });
+ }
+
+ // Figure out how far into this option we are.
+ let mut next_char_index = context
+ .shell
+ .env_str(VAR_GETOPTS_NEXT_CHAR_INDEX)
+ .map_or(DEFAULT_NEXT_CHAR_INDEX, |s| {
+ s.parse().unwrap_or(DEFAULT_NEXT_CHAR_INDEX)
+ });
+
+ // Find the char. If the index is stale (exceeds this arg's length),
+ // reset to the default index, mirroring bash behavior.
+ let mut c = arg.chars().nth(next_char_index);
+ if c.is_none() {
+ next_char_index = DEFAULT_NEXT_CHAR_INDEX;
+ c = arg.chars().nth(next_char_index);
+ }
+ let Some(c) = c else {
+ // Arg is too short even at default index.
+ return Ok(GetOptsResult {
+ variable_value: String::from("?"),
+ optarg: None,
+ optind: next_index,
+ exit_code: ExecutionResult::general_error(),
+ });
+ };
+
+ let arg_char_count = arg.chars().count();
+ let mut is_last_char_in_option = next_char_index == arg_char_count - 1;
+
+ let mut variable_value;
+ let optarg;
+
+ // Look up the char in the option spec.
+ if let Some(takes_arg) = spec.defs.get(&c) {
+ variable_value = String::from(c);
+
+ if *takes_arg {
+ (variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument(
+ context,
+ spec,
+ c,
+ arg,
+ args_to_parse,
+ next_char_index,
+ is_last_char_in_option,
+ next_index,
+ )?;
+ } else {
+ optarg = None;
+ }
+ } else {
+ (variable_value, optarg) = report_unknown_option(context, spec, c)?;
+ }
+
+ let optind = if is_last_char_in_option {
+ // We're done with this argument, so unset the internal char index variable
+ // and request an update to OPTIND.
+ context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
+ next_index + 1
+ } else {
+ // We have more to go in this argument, so update the internal char index
+ // and request that OPTIND not be updated.
+ context.shell.env_mut().update_or_add(
+ VAR_GETOPTS_NEXT_CHAR_INDEX,
+ variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
+ |v| {
+ v.hide_from_enumeration();
+ Ok(())
+ },
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+ next_index
+ };
+
+ Ok(GetOptsResult {
+ variable_value,
+ optarg,
+ optind,
+ exit_code: ExecutionResult::success(),
+ })
+}
+
+/// Resolves the argument for an option that takes a value. Returns the updated
+/// `(variable_value, optarg, is_last_char, next_index)` tuple.
+#[allow(clippy::too_many_arguments)]
+fn resolve_option_argument(
+ context: &brush_core::ExecutionContext<'_, SE>,
+ spec: &OptionSpec,
+ c: char,
+ arg: &str,
+ args_to_parse: &[String],
+ next_char_index: usize,
+ is_last_char_in_option: bool,
+ mut next_index: usize,
+) -> Result<(String, Option, bool, usize), brush_core::Error> {
+ // If this is the last character in the token, the argument value comes from
+ // the next token. Otherwise, the remainder of the current token is the value.
+ if is_last_char_in_option {
+ if next_index >= args_to_parse.len() {
+ // Missing required argument.
+ let (variable_value, optarg) = if spec.silent_errors {
+ (String::from(":"), Some(String::from(c)))
+ } else {
+ if is_opterr_enabled(context) {
+ writeln!(
+ context.stderr(),
+ "getopts: option requires an argument -- {c}"
+ )?;
+ }
+ (String::from("?"), None)
+ };
+ Ok((variable_value, optarg, true, next_index))
+ } else {
+ next_index += 1;
+ Ok((
+ String::from(c),
+ Some(args_to_parse[next_index - 1].clone()),
+ true,
+ next_index,
+ ))
+ }
+ } else {
+ let optarg: String = arg.chars().skip(next_char_index + 1).collect();
+ Ok((String::from(c), Some(optarg), true, next_index))
+ }
+}
+
+/// Handles an unknown option character, reporting an error if appropriate.
+fn report_unknown_option(
+ context: &brush_core::ExecutionContext<'_, SE>,
+ spec: &OptionSpec,
+ c: char,
+) -> Result<(String, Option), brush_core::Error> {
+ if !spec.silent_errors && is_opterr_enabled(context) {
+ writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
+ }
+
+ let optarg = if spec.silent_errors {
+ Some(String::from(c))
+ } else {
+ None
+ };
+
+ Ok((String::from("?"), optarg))
+}
+
+/// Writes the parsing result back into shell variables: the target variable,
+/// OPTARG, OPTIND, and the internal `__GETOPTS_LAST_OPTIND` tracker.
+fn update_variables(
+ context: &mut brush_core::ExecutionContext<'_, SE>,
+ variable_name: &str,
+ result: GetOptsResult,
+) -> Result {
+ // Update variable value.
+ context.shell.env_mut().update_or_add(
+ variable_name,
+ variables::ShellValueLiteral::Scalar(result.variable_value),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+
+ // Update OPTARG
+ if let Some(optarg) = result.optarg {
+ context.shell.env_mut().update_or_add(
+ "OPTARG",
+ variables::ShellValueLiteral::Scalar(optarg),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+ } else {
+ context.shell.env_mut().unset("OPTARG")?;
+ }
+
+ // Update OPTIND and record it so we can detect external modifications.
+ let optind_str = result.optind.to_string();
+ context.shell.env_mut().update_or_add(
+ "OPTIND",
+ variables::ShellValueLiteral::Scalar(optind_str.clone()),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+ context.shell.env_mut().update_or_add(
+ VAR_GETOPTS_LAST_OPTIND,
+ variables::ShellValueLiteral::Scalar(optind_str),
+ |v| {
+ v.hide_from_enumeration();
+ Ok(())
+ },
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+
+ Ok(result.exit_code)
+}
+
+/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
+/// OPTERR defaults to 1; any nonzero value means errors are enabled.
+fn is_opterr_enabled(
+ context: &brush_core::ExecutionContext<'_, SE>,
+) -> bool {
+ context
+ .shell
+ .env_str("OPTERR")
+ .is_none_or(|s| s.parse::().unwrap_or(1) != 0)
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/hash.rs b/local/recipes/shells/brush/source/brush-builtins/src/hash.rs
new file mode 100644
index 0000000000..b8dda4289c
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/hash.rs
@@ -0,0 +1,109 @@
+use clap::Parser;
+use std::{io::Write, path::PathBuf};
+
+use brush_core::{ExecutionResult, builtins};
+
+#[derive(Parser)]
+pub(crate) struct HashCommand {
+ /// Remove entries associated with the given names.
+ #[arg(short = 'd')]
+ remove: bool,
+
+ /// Display paths in a format usable for input.
+ #[arg(short = 'l')]
+ display_as_usable_input: bool,
+
+ /// The path to associate with the names.
+ #[arg(short = 'p', value_name = "PATH")]
+ path_to_use: Option,
+
+ /// Remove all entries.
+ #[arg(short = 'r')]
+ remove_all: bool,
+
+ /// Display the paths associated with the names.
+ #[arg(short = 't')]
+ display_paths: bool,
+
+ /// Names to process.
+ names: Vec,
+}
+
+impl builtins::Command for HashCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ if self.remove_all {
+ context.shell.program_location_cache_mut().reset();
+ } else if self.remove {
+ for name in &self.names {
+ if !context.shell.program_location_cache_mut().unset(name) {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else if self.display_paths {
+ for name in &self.names {
+ if let Some(path) = context.shell.program_location_cache().get(name) {
+ if self.display_as_usable_input {
+ writeln!(
+ context.stdout(),
+ "builtin hash -p {} {name}",
+ path.to_string_lossy()
+ )?;
+ } else {
+ let mut prefix = String::new();
+
+ if self.names.len() > 1 {
+ prefix.push_str(name.as_str());
+ prefix.push('\t');
+ }
+
+ writeln!(
+ context.stdout(),
+ "{prefix}{}",
+ path.to_string_lossy().as_ref()
+ )?;
+ }
+ } else {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else if let Some(path) = &self.path_to_use {
+ for name in &self.names {
+ context
+ .shell
+ .program_location_cache_mut()
+ .set(name, path.clone());
+ }
+ } else {
+ for name in &self.names {
+ // Remove from the cache if already hashed.
+ let _ = context.shell.program_location_cache_mut().unset(name);
+
+ // Names with slashes are accepted silently
+ if name.contains('/') {
+ continue;
+ }
+
+ // Hash the path
+ if context
+ .shell
+ .find_first_executable_in_path_using_cache(name)
+ .is_none()
+ {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(result)
+ }
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/help.rs b/local/recipes/shells/brush/source/brush-builtins/src/help.rs
new file mode 100644
index 0000000000..bde2bdaed7
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/help.rs
@@ -0,0 +1,160 @@
+use brush_core::{ExecutionResult, builtins};
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+/// Display command help.
+#[derive(Parser)]
+pub(crate) struct HelpCommand {
+ /// Display a short description for the commands.
+ #[arg(short = 'd')]
+ short_description: bool,
+
+ /// Display a man-style page of documentation for the commands.
+ #[arg(short = 'm')]
+ man_page_style: bool,
+
+ /// Display a short usage summary for the commands.
+ #[arg(short = 's')]
+ short_usage: bool,
+
+ /// Patterns of topics to display help for.
+ topic_patterns: Vec,
+}
+
+impl builtins::Command for HelpCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_, SE>,
+ ) -> Result {
+ if self.topic_patterns.is_empty() {
+ Self::display_general_help(&context)?;
+ return Ok(ExecutionResult::success());
+ }
+
+ // Match bash: succeed if at least one requested topic pattern matched
+ // something; return a non-zero exit code only when none of them matched.
+ let mut any_matched = false;
+ for topic_pattern in &self.topic_patterns {
+ if self.display_help_for_topic_pattern(&context, topic_pattern)? {
+ any_matched = true;
+ }
+ }
+
+ if any_matched {
+ Ok(ExecutionResult::success())
+ } else {
+ Ok(ExecutionResult::general_error())
+ }
+ }
+}
+
+impl HelpCommand {
+ fn display_general_help(
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ ) -> Result<(), brush_core::Error> {
+ const COLUMN_COUNT: usize = 3;
+
+ if let Some(display_str) = context.shell.product_display_str() {
+ writeln!(context.stdout(), "{display_str}\n")?;
+ }
+
+ writeln!(
+ context.stdout(),
+ "The following commands are implemented as shell built-ins:"
+ )?;
+
+ let builtins = get_builtins_sorted_by_name(context);
+ let items_per_column = builtins.len().div_ceil(COLUMN_COUNT);
+
+ for i in 0..items_per_column {
+ for j in 0..COLUMN_COUNT {
+ if let Some((name, builtin)) = builtins.get(i + j * items_per_column) {
+ let prefix = if builtin.disabled { "*" } else { " " };
+ write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired
+ // column width
+ }
+ }
+ writeln!(context.stdout())?;
+ }
+
+ Ok(())
+ }
+
+ /// Displays help for the builtins matching `topic_pattern`, returning whether
+ /// at least one topic matched.
+ fn display_help_for_topic_pattern(
+ &self,
+ context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
+ topic_pattern: &str,
+ ) -> Result {
+ let pattern = brush_core::patterns::Pattern::from(topic_pattern)
+ .set_extended_globbing(context.shell.options().extended_globbing)
+ .set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion);
+
+ let mut matched = false;
+ for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) {
+ if pattern.exactly_matches(builtin_name.as_str())? {
+ self.display_help_for_builtin(
+ context,
+ builtin_name.as_str(),
+ builtin_registration,
+ )?;
+ matched = true;
+ }
+ }
+
+ if !matched {
+ writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?;
+ }
+
+ Ok(matched)
+ }
+
+ fn display_help_for_builtin(
+ &self,
+ context: &brush_core::ExecutionContext<'_, SE>,
+ name: &str,
+ registration: &builtins::Registration,
+ ) -> Result<(), brush_core::Error> {
+ let content_type = if self.short_description {
+ builtins::ContentType::ShortDescription
+ } else if self.man_page_style {
+ builtins::ContentType::ManPage
+ } else if self.short_usage {
+ builtins::ContentType::ShortUsage
+ } else {
+ builtins::ContentType::DetailedHelp
+ };
+
+ let Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else {
+ // If there's no stdout, nothing to do.
+ return Ok(());
+ };
+
+ // For now, we assume colorized output if stdout is a terminal.
+ let options = builtins::ContentOptions {
+ colorized: stdout.is_terminal(),
+ };
+
+ let content = (registration.content_func)(name, content_type, &options)?;
+
+ write!(stdout, "{content}")?;
+ stdout.flush()?;
+
+ Ok(())
+ }
+}
+
+fn get_builtins_sorted_by_name<'a, SE: brush_core::ShellExtensions>(
+ context: &'a brush_core::ExecutionContext<'_, SE>,
+) -> Vec<(&'a String, &'a builtins::Registration)> {
+ context
+ .shell
+ .builtins()
+ .iter()
+ .sorted_by_key(|(name, _)| *name)
+ .collect()
+}
diff --git a/local/recipes/shells/brush/source/brush-builtins/src/history.rs b/local/recipes/shells/brush/source/brush-builtins/src/history.rs
new file mode 100644
index 0000000000..8d703ae696
--- /dev/null
+++ b/local/recipes/shells/brush/source/brush-builtins/src/history.rs
@@ -0,0 +1,246 @@
+use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
+use clap::Parser;
+use std::{
+ io::Write,
+ path::{Path, PathBuf},
+};
+
+/// Query or manipulate the shell's command history.
+// TODO(history): Evaluate which of the options conflict with each other.
+#[derive(Parser)]
+#[expect(clippy::option_option)]
+pub(crate) struct HistoryCommand {
+ /// Clears all history.
+ #[arg(short = 'c')]
+ clear_history: bool,
+
+ /// Deletes the history entry at the given offset. Positive offsets are relative to the
+ /// beginning of the history, while negative offsets are relative to the end of the history.
+ #[arg(short = 'd', value_name = "OFFSET")]
+ delete_offset: Option,
+
+ /// Appends the history from the current session to the history file.
+ #[arg(short = 'a', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
+ append_session_to_file: Option