The spirit messenger between you and your shell.
Describe what you need. Gou Mang delivers the command. You confirm. It executes.
Click image to view full resolution
βββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ε₯θ Β· Gou Mang Β· Spirit Messenger β
β β
β ββββ ββββ ββββββ ββββ βββ βββββββ β
β βββββ ββββββββββββββββββ βββββββββββ β
β βββββββββββββββββββββββββββββββ ββββ β
β βββββββββββββββββββββββββββββββ βββ β
β βββ βββ ββββββ ββββββ ββββββββββββββ β
β βββ ββββββ ββββββ ββββ βββββββ β
β β
β βββββββ βββ βββ β
β βββββββββββ βββ β
β ββββββββββββββββ β
β ββββββββββββββββ β
β βββββββββββ βββ β
β βββββββββββ βββ β
β β
β v3.0.4 Β· mang.sh Β· github.com/paulfxyz β
βββββββββββββββββββββββββββββββββββββββββββββββββ
This README is a deeply technical reference β it documents not just how to use mang.sh, but why every architectural decision was made, every bug that was hit, and every lesson learned building it across 13 versions. If you're building a similar tool or want to understand how a production-grade AI CLI is architected in Rust, read on.
- Gou Mang β the myth
- Why this exists
- Why Rust
- Feature overview
- Install
- See it in action
- Ollama β local, private, offline
- Named shortcuts
- All shortcuts
- Code structure
- Architecture deep dive
- Bugs worth documenting
- Building with Perplexity Computer
- Lessons learned
- Community telemetry
- Model recommendations
- Changelog
- Contributing
- Author
In ancient Chinese mythology, Gou Mang (ε₯θ) served as the divine messenger between the Emperor of Heaven and the mortal world. He carried intent across the boundary between realms β translating the will of heaven into action on earth.
mang.sh is named in his honour.
You type what you want in plain English. Gou Mang translates that intent into the exact shell command your machine understands. You confirm β he executes. No ceremony. No Stack Overflow. No translation tax.
The command to invoke him is yo β casual, direct, immediate. The way you'd ask a friend. No incantation required.
"Gou Mang carried messages between the Emperor of Heaven and the mortal world. Now he carries yours β between human intent and machine command."
I'm Paul Fleury β French internet entrepreneur based in Lisbon. I run Openline and manage infrastructure across multiple products: DNS, Docker stacks, reverse proxies, SSL certs, CI/CD pipelines, API integrations, cron jobs. The full sysadmin surface.
And I kept hitting the same wall β not the hard problems, but the tedious ones.
The find flags for deleting files older than 7 days. The rsync invocation that syncs without wiping the destination. The awk one-liner for column 3 of a log. The openssl command that decodes a cert. The lsof incantation to kill port 3000. Things I've done dozens of times but never fully memorised because I don't type them every single day.
Every time: stop β open browser β Google it β skip ads β scan Stack Overflow β adapt the 2016 answer β test it. Five minutes gone. Ten times a day. That's an hour of command-syntax archaeology, daily.
I wanted something that felt like messaging a developer friend who knows Linux cold. Describe the thing. Get the command. Run it.
Three constraints I set from day one:
- No runtime. One compiled binary. Works on any machine, forever, without installation ceremonies.
- One command to install.
curl | bash. Even Rust installs automatically. - Any AI model. OpenRouter for cloud (GPT-4o, Claude, Llama). Ollama for offline, air-gapped, private.
Rust answered all three. This document explains why.
π‘ Designed and built in collaboration with Perplexity Computer β architecture through implementation, debugging, and documentation. Human intent + AI execution.
A Python CLI needs Python, the right version, the right packages, the right virtualenv. A Node.js tool needs Node, npm, and potentially hundreds of packages in node_modules. These aren't theoretical concerns β they cause real failures in production. The wrong Python version, a missing package, a broken lockfile.
A compiled Rust binary is a single self-contained executable. Copy it to any machine with the same OS and architecture β it works. No interpreter, no runtime, no dependencies. yo on macOS or Linux. yo.exe on Windows. That's it.
For a tool you want to install once and trust forever, this is non-negotiable.
Rust compiles to native machine code via LLVM β the same optimisation infrastructure used by C and C++. For mang.sh, the bottleneck is always the AI network call (500msβ3s). The binary itself starts in under 10ms. No interpreter warmup, no GC pause, no JIT compilation lag. The prompt appears instantly.
Rust's ownership system enforces memory safety at compile time, without a garbage collector. For mang.sh this matters concretely: we spawn background threads for telemetry submissions. In C++, passing data to a thread while the main thread continues is a minefield. In Rust, the compiler refuses to compile code that would create a data race. The telemetry thread's data is moved, not shared β enforced at compile time, not runtime.
Rust's type system encodes invariants at compile time:
Option<T>forces handling the "absent" case β no null pointer exceptionsResult<T, E>forces handling errors β no uncaught exceptions- Exhaustive pattern matching: add a new enum variant and the compiler tells you every match that doesn't handle it
In mang.sh, the ShellKind enum covers zsh, bash, fish, sh, PowerShell 5, PowerShell 7, cmd.exe, and Git Bash. Add a new shell β the compiler flags every unhandled match. In Python, that's a runtime bug shipped to users.
cargo is one of the best build tools ever designed:
cargo checktype-checks in seconds without buildingcargo clippycatches logic errors and anti-patterns beyond the type checkerCargo.tomlis a clean, readable manifest with exact version locking- Cross-compilation built in
The entire mang.sh project builds with cargo build --release. No Makefile, no CMakeLists.txt, no Gradle.
The misconception: Rust is for operating systems, game engines, embedded firmware. The reality: Rust is excellent for CLI tools and developer utilities. Compile times are longer than Python (improving rapidly). The learning curve is steeper. The result: a binary that ships, works everywhere, starts instantly, and cannot crash due to memory errors or type errors. For a tool that runs shell commands on your machine, that's the right trade-off.
| Feature | Detail |
|---|---|
| π£οΈ Natural language | Plain English β shell commands via any OpenRouter model or local Ollama |
| β Always confirms | Every suggestion requires Y before anything runs |
| β‘ Single binary | No Python, Node.js, or runtime β one file, works everywhere |
| π Local config | API key stored in your OS config directory only |
| ε₯θ Spirit banner | Block-letter MANG / .sh with Gou Mang (ε₯θ) subtitle on every launch |
| π§ Intent detection | "use ollama" / "change model" triggers reconfiguration without API call |
| π Rich shortcuts | !help, !api, !feedback, !shortcuts, !context, !update, !exit |
| π§ Prompt wizard | !prompt / !p β guided 3-question mode when you're stuck or request is vague |
| πͺͺ Credits screen | !credits / !cr β author info, project links, build stack |
| π Three aliases | yo, hi, hello β all invoke the spirit messenger |
| π Context-aware | OS, arch, CWD, and precise shell sent with every request |
| π‘οΈ Safe prompting | Temperature 0.2 β deterministic, conservative suggestions |
| π¬ Explanations | Every suggestion includes a plain-English description |
| π Ollama support | Local AI β no API key, no internet, complete privacy |
| π Multi-turn context | Follow-up prompts: "now do the same for /tmp" works |
| π Shell history | Confirmed commands appended to ~/.zsh_history / ~/.bash_history |
| π§ͺ Dry-run | yo --dry β preview every command before any execution |
| π Refinement | N on a suggestion β describe what to change β Mang adjusts |
| πͺ Feedback loop | "Did that work?" with AI refinement if it didn't |
| πͺ Windows native | PS5, PS7, cmd.exe, Git Bash β detected, correct syntax generated |
| πΎ Named shortcuts | !save, !forget, instant replay with !<name> |
| π Update check | Background version check on every launch, !update to install |
| π Telemetry | Opt-in community data sharing via JSONBin.io |
macOS / Linux:
curl -fsSL https://mang.sh/install | bashWindows β PowerShell (native, no Git Bash needed):
iwr -useb https://mang.sh/install.ps1 | iexWindows β Git Bash or WSL2:
curl -fsSL https://mang.sh/install | bash
β οΈ On Windows,curlin PowerShell is an alias forInvoke-WebRequestand does not accept-fsSL. Always useiwror open Git Bash.
Update:
curl -fsSL https://mang.sh/update | bash # macOS/Linux
iwr -useb https://mang.sh/update.ps1 | iex # Windows PSUninstall:
curl -fsSL https://mang.sh/uninstall | bash # macOS/Linux
iwr -useb https://mang.sh/uninstall.ps1 | iex # Windows PSFull guide: INSTALL.md
$ yo
[banner β Gou Mang's tree + MANG.SH logotype]
β Backend: OpenRouter model: openai/gpt-4o-mini
β Context: 5 turns
yo βΊ find all log files older than 7 days and delete them
β Finds .log files not modified in 7+ days and removes them.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $ find . -name "*.log" -mtime +7 -type f -delete β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Run it? [Y/n] βΊ N
β Let's refine β what should be different?
β (Describe the change, or press Enter / !skip to cancel)
yo βΊ only in the /var/log folder, not here
β Thinkingβ¦
β Applies the same log cleanup but restricted to /var/log.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $ find /var/log -name "*.log" -mtime +7 -type f -deleteβ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Run it? [Y/n] βΊ Y
βΊ find /var/log -name "*.log" -mtime +7 -type f -delete
β Done.
Did that work? [Y/n] βΊ Y
β Great! What else?
yo [+1] βΊ now do the same for /tmp
β Thinkingβ¦
β Same cleanup applied to /tmp.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $ find /tmp -name "*.log" -mtime +7 -type f -delete β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Notice: pressing N opens a refinement loop β describe what to change, Mang adjusts. The [+1] in the prompt shows how many prior turns Mang remembers.
Run mang.sh entirely on your own machine, zero network traffic:
# Install Ollama
curl -fsSL https://ollama.ai/install.sh | sh
# Pull a model (one-time, ~2GB)
ollama pull llama3.2
# Launch β choose Ollama during setup
yoOr switch mid-session:
yo βΊ use ollama
| Model | Pull | Best for |
|---|---|---|
llama3.2 |
ollama pull llama3.2 |
General β best default |
mistral |
ollama pull mistral |
Fast, great at commands |
codellama |
ollama pull codellama |
Code-heavy sessions |
yo βΊ docker restart myapp && docker logs --tail 50 myapp
β Done.
Did that work? [Y/n] βΊ Y
yo βΊ !save restartapp
# Any time later β no AI, no confirmation:
yo βΊ !restartapp
β Running shortcut !restartapp
βΊ docker restart myapp && docker logs --tail 50 myapp
| Command | Action |
|---|---|
!save <name> |
Save last confirmed commands as !name |
!<name> |
Run instantly β no AI, no Y/N |
!forget <name> |
Remove a shortcut |
!shortcuts |
List all saved shortcuts |
Persisted to ~/.config/mang/shortcuts.json across sessions.
| Input | What happens |
|---|---|
!prompt / !p |
Advanced Prompt Mode β up to 3 AI questions to clarify a vague request |
!credits / !cr |
About mang.sh β author, links, build stack, mythology |
!help / !h |
Full help screen |
!update / !check |
Check for a new version, offer to install |
!api |
Reconfigure backend, model, API key, history, context |
!feedback / !fb |
Telemetry status, opt-in/out, personal JSONBin |
!shortcuts / !sc |
List all saved shortcuts |
!save <name> |
Save last commands as !<name> |
!forget <name> |
Remove a shortcut |
!<name> |
Run a shortcut instantly |
!context / !ctx |
Show Mang's current memory |
!clear |
Clear conversation context |
!exit / !q |
Dismiss Mang |
Y / Enter |
Confirm and run |
N |
Refine β describe what to change |
β / β |
Recall previous prompts |
Ctrl+D |
Exit at any time |
yo --dry # Dry-run: show commands, never execute
yo -d # Short form
yo --no-history # Disable shell history appending this session
yo --no-context # Disable multi-turn context this session
yo --help # Show all flags
yo --version # Show versionmang-sh/
βββ src/
β βββ main.rs Entry point, REPL loop, execution, telemetry handle tracking
β β All 4 exit paths join pending_telemetry before returning
β βββ ai.rs OpenRouter + Ollama HTTP calls, JSON envelope, intent detection
β β suggest_commands() β Suggestion | suggest_raw() β String
β βββ prompt_wizard.rs Advanced Prompt Mode β 3-question Socratic dialogue
β β coach_prompt() β suggest_raw() β synthesise() β suggest_commands()
β βββ config.rs Load/save ~/.config/mang/config.json, interactive setup
β βββ shell.rs ShellKind enum (8 variants), detection matrix, syntax hint
β βββ context.rs ConversationContext β rolling window of N prior turns
β βββ history.rs Shell history appending β zsh EXTENDED_HISTORY, bash, fish
β βββ shortcuts.rs ShortcutStore β save/run/forget, persisted to shortcuts.json
β βββ updater.rs Background version check (rate-limited 24h), !update handler
β βββ telemetry.rs JSONBin.io background POST, JoinHandle tracking, MANGDEBUG
β βββ feedback.rs !feedback subcommands β setup, on/off, personal, test, about
β βββ cli.rs clap Args β --dry/-d, --no-history, --no-context
β βββ ui.rs Banner, help, credits, suggestion display, context summary
βββ Cargo.toml Manifest β all deps annotated with purpose
βββ yo.sh Unix installer β Rust auto-install, binary build, aliases
βββ update.sh Unix updater β in-place binary replacement
βββ uninstall.sh Unix uninstaller β binary + config + aliases, /dev/tty Y/N
βββ install.ps1 Windows PowerShell installer (PS5 + PS7 compatible)
βββ update.ps1 Windows updater
βββ uninstall.ps1 Windows uninstaller
βββ README.md You're reading it
βββ INSTALL.md Full install/update/uninstall reference
βββ CHANGELOG.md Complete version history (all 13 versions)
βββ LICENSE MIT
Every LLM wants to be conversational. Ask for "a shell command to list files" and you'll get prose, markdown fences, explanations, caveats. None of that is machine-parseable.
mang.sh forces the model to respond exclusively with this JSON schema:
{
"commands": ["cmd1", "cmd2"],
"explanation": "One plain-English sentence."
}The system prompt states this schema twice (once in rules, once as an example) and includes numbered constraints. We also strip accidental markdown fences before parsing. Both OpenRouter and Ollama backends go through the same parser. This single design decision β structured output over freeform β is what makes every other feature possible.
Shell commands aren't creative. Temperature 0.2 is low enough that the model picks the conventional, widely-understood command form rather than an exotic variant. It's high enough to handle natural language variation without getting stuck. Tested across GPT-4o-mini, Claude 3 Haiku, Claude 3.5 Sonnet, and Llama 3.2 β 0.2 produces correct, safe commands in over 95% of cases.
Without context, a model asked "open the downloads folder" has to guess the platform. With OS=macos ARCH=aarch64 CWD=/Users/paul SHELL=zsh syntax=posix prepended to every request, the model knows: use open, use brew, use pbcopy, use arm64 binary paths, use POSIX syntax. Four fields. Measurable improvement in correctness.
The syntax=posix / syntax=powershell / syntax=cmd hint is the highest-leverage addition: it explicitly tells the model which shell syntax family to use, eliminating the most common cross-platform errors. PowerShell 5 doesn't support && β the model knows this and uses ; instead.
Each confirmed prompt+command pair is injected as prior user/assistant message pairs before the new request:
[
{ "role": "system", "content": "<system prompt>" },
{ "role": "user", "content": "find log files older than 7 days" },
{ "role": "assistant", "content": "{\"commands\": [\"find . -name '*.log' -mtime +7\"]}" },
{ "role": "user", "content": "now do the same for /tmp" }
]The window is bounded (default 5 turns, configurable) to prevent unbounded token growth. Oldest turns evicted first.
Previously, pressing N returned you to a blank prompt. You had to retype your entire request. Now N opens an inline refinement loop: the user describes what to change, mang.sh constructs a context-aware prompt including the original request and previous suggestion, and the AI produces an adjusted command. The loop continues until Y or explicit cancel (!skip, blank Enter, Ctrl-D).
This transforms mang.sh from a one-shot tool into a conversational assistant that learns from your feedback without requiring you to repeat yourself.
Each shell expects its own history format:
- zsh:
: <unix_timestamp>:0;<command>(EXTENDED_HISTORY) - bash: plain
<command>one per line - fish:
- cmd: <command>\n when: <timestamp>(YAML-like)
We detect which format from $SHELL, $ZDOTDIR, and $HISTFILE. Writing to the file doesn't update the live shell's in-memory buffer β history -r or a new terminal window picks up the entries.
Background threads are killed when the process exits. If you confirm a command and immediately type !exit, a naive fire-and-forget thread never completes its HTTP POST. mang.sh stores every JoinHandle<()> returned by submit_background() in a Vec<JoinHandle<()>>. Every exit path (Ctrl-D, Ctrl-C, !exit, input error) calls h.join() on all handles before returning. The network requests complete before the process terminates.
The most counterintuitive Windows bug: $ErrorActionPreference = "Stop" + 2>&1 kills the build script when cargo writes progress to stderr β even on success. cargo writes all progress output to stderr, not stdout. Every "Compiling foo v1.0" line becomes an ErrorRecord that triggers the Stop preference. Fix: remove $ErrorActionPreference = "Stop", remove 2>&1, let cargo output flow freely, check $LASTEXITCODE after.
The main loop in main.rs is a blocking loop {} with a rustyline readline call at the top. Here's why every major decision was made the way it was:
Why synchronous? One AI call per user turn. The user is the bottleneck β they type, they read, they decide. Adding tokio for async would cost ~200 KB of binary size, 30 additional seconds of first compile time, and produce zero improvement in responsiveness from the user's perspective. Async is for servers. This is a CLI tool.
Why rustyline and not raw stdin.read_line()? Raw stdin.read_line() gives you no arrow key editing, no Ctrl-W word delete, no β/β session history. A REPL without readline feels broken. rustyline is the readline equivalent for Rust β battle-tested, used by evcxr (the Rust REPL), supports all expected key bindings, and integrates in five lines. The add_history_entry() call keeps in-session history separate from the shell's own history file (which history.rs handles as a distinct concern).
Why is the context window bounded? The ConversationContext holds at most N prior (prompt, commands) pairs. N is configurable (default 5). Without a bound, a long session would inject an unbounded token payload into every AI request β costs money, slows responses, and eventually exceeds context window limits on smaller models. Five turns is enough to resolve any pronoun or relative reference a user would realistically make in a single task.
The four exit paths problem. The main REPL has exactly four ways to exit:
Ctrl-DβReadlineError::EofCtrl-CβReadlineError::Interrupted!exit/!qβ explicit shortcut- readline error β other
ReadlineErrorvariant
Every single one must call h.join() on all pending telemetry handles before returning. This was not the case in v2.3.0 β detached threads were killed on process exit, silently losing every telemetry entry. The fix was a Vec<JoinHandle<()>> that accumulates handles and is joined at all four exit points. Finding all four exit points is the kind of audit that's easy to miss on the first pass.
The system prompt in ai.rs::SYSTEM_PROMPT is a constant string, but it's where most of the product design lives. Some specific decisions:
Why are the rules numbered? Because LLMs follow numbered lists better than prose paragraphs. The numbered format also makes it easy to reference specific rules when debugging model behaviour.
Why state the JSON schema twice? Rule 2 states the schema abstractly. The example restates it concretely. On smaller models like Llama 3.2 7B, having the schema appear only once produced ~60% JSON compliance. Adding a concrete example brought it to ~92%. The redundancy is intentional.
Why rule 7 (empty commands fallback)? Without an explicit escape hatch for non-shell requests, the model either invents nonsensical shell commands or produces malformed JSON. Rule 7 gives it a clean output: {"commands": [], "explanation": "I cannot express this as a shell command."}. The empty commands array triggers the prompt wizard (v3.0.2+) rather than showing a dead-end error.
Why temperature 0.2 and not 0? Full greedy decoding (temperature=0) creates a failure mode where ambiguous prompts get stuck producing the same wrong command repeatedly. Temperature 0.2 introduces just enough variability to break out of local optima while keeping the output firmly in the "conventional and correct" zone. Tested across GPT-4o-mini, Claude 3 Haiku, Claude 3.5 Sonnet, and Llama 3.2 β 0.2 is consistently correct.
Despite the system prompt, some models (particularly smaller Ollama models) occasionally wrap their output in markdown fences. The parse_suggestion() function strips these before attempting JSON parse:
let cleaned = raw
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();Order matters: try the longer prefix first. This is belt-and-suspenders engineering β the system prompt says "no markdown fences" and we strip them anyway, because the cost of the strip is zero and the cost of a parse failure is a broken user experience.
We parse into serde_json::Value rather than a typed struct for three reasons:
- Missing fields return
Noneinstead of panicking - The raw response is embedded in the error message for debugging
- The
explanationfield is truly optional withoutOption<String>ceremony
shell.rs implements a ShellKind enum with 8 variants:
ShellKind::Zsh β $SHELL ends in "zsh" OR $ZSH_VERSION is set
ShellKind::Bash β $SHELL ends in "bash" AND not Git Bash
ShellKind::Fish β $SHELL ends in "fish" OR $FISH_VERSION is set
ShellKind::Sh β $SHELL ends in "/sh" (non-bash POSIX shells)
ShellKind::GitBash β Windows + MINGW or MSYS in $MSYSTEM
ShellKind::PowerShell5 β Windows + PSVersion.Major == 5
ShellKind::PowerShell7 β Windows + PSVersion.Major >= 7
ShellKind::Cmd β Windows + none of the above (cmd.exe fallback)
ShellKind::Unknown β last resort
The syntax= hint derived from this detection is the single most impactful addition to the AI context. Before it existed, Windows users got POSIX-syntax commands β which worked in Git Bash but failed in cmd.exe and PowerShell. The syntax=cmd and syntax=powershell hints tell the AI exactly which syntax family to target.
Why PowerShell 5 and 7 are distinct: PS5 doesn't support && for command chaining. PS7 does. Version-specific detection means PS5 users get ;-separated commands and PS7 users get && chains β both correct for their environment.
The Git Bash trap: On Windows, Git Bash sets $SHELL=/usr/bin/bash β identical to native bash on Linux. Without the $MSYSTEM environment variable check (which Git Bash sets to MINGW64), mang.sh would detect Git Bash as plain bash and generate commands that fail in the user's primary Windows shell.
The wizard in prompt_wizard.rs is built around one key insight: the AI needs to ask questions, not generate commands. These are fundamentally different tasks requiring different system prompts, temperatures, and output contracts.
User's vague prompt
β
coach_prompt() β "prompt coach" system message, temperature=0.5
β
ai::suggest_raw() β freeform call, no JSON schema, max_tokens=256
β
One plain question β prose, no JSON required
β
User answers (up to 3Γ)
β
synthesise() β pure string join, NO extra AI call
β
suggest_commands() β normal pipeline with enriched compound prompt
Why suggest_raw() instead of reusing suggest_commands()? The JSON schema forces the AI to produce {"commands": [...], "explanation": "..."}. When you ask an AI to generate a clarifying question through this schema, it tries to fit the question into explanation and produces nonsense in commands. A separate function with a separate contract β "just return plain text, one short question" β is the correct solution.
Why is synthesis a string join and not another AI call? The first design passed all Q&A back to the AI to synthesise a clean prompt. This added one more network round-trip (500msβ3s), was non-deterministic, and occasionally produced worse prompts than naive concatenation. Joining the original prompt and user answers with ". " feeds a richer context string to suggest_commands() and lets the downstream AI handle disambiguation. Deterministic, instant, offline.
pub struct ShortcutStore {
pub shortcuts: HashMap<String, Vec<String>>,
}A flat HashMap<String, Vec<String>>. Serialised to ~/.config/mang/shortcuts.json. Three decisions:
Names stored without !. The ! is invocation syntax, not the name. "restartapp": ["docker restart myapp"] is readable and manually editable. "!restartapp" is noisier.
Normalised to lowercase. !RestartApp and !restartapp are the same shortcut. Normalised at storage time.
No confirmation on replay. The user saved this intentionally. The confirmation gate exists for AI-generated suggestions because they're unknown β a saved shortcut is known by definition.
These are the non-obvious bugs in mang.sh's history that were instructive or affected real users.
Symptom: The JSONBin collection was empty. Every telemetry entry was silently lost. !feedback test (which uses the synchronous path) worked fine, making it look like a server-side issue.
Cause: submit_background() returns a JoinHandle<()>. The calling code in main.rs dropped it immediately β it was never stored. In Rust, dropping a JoinHandle detaches the thread. When main() returns, the OS kills all detached threads, and the HTTP POST never completes.
The insidious part: !feedback test uses submit_sync() β a blocking call. The sync path worked. The async path silently failed. These are different code paths with different failure modes.
Fix: Vec<JoinHandle<()>> in main(), populated on every submit_background() call, drained at all four exit paths.
Lesson: A dropped JoinHandle is not an error β Rust lets you do this deliberately. But an implicit drop that happens because you forgot to store the handle is a silent race condition. The #[must_use] attribute on JoinHandle produces a compiler warning if you discard it β but only if clippy is run with -D warnings.
Symptom: Running curl -fsSL https://mang.sh/uninstall | bash always printed "Cancelled" regardless of user input.
Cause: When a script runs via curl | bash, its stdin is the pipe (the script source). read -r reply reads from stdin β which is the pipe β returns EOF immediately when the script content is exhausted, and stores an empty string. The empty string triggers the [Y/n] default of "n" β "Cancelled".
Fix: read -r reply </dev/tty β reads from the terminal directly, bypassing the pipe.
Why this is easy to miss: In development, you test the script by running it directly (bash uninstall.sh). Stdin is the terminal. Everything works. The pipe-stdin issue only manifests when the script is piped from curl, exactly the production usage pattern.
Symptom: update.sh printed raw escape sequences: οΏ½[0;36m instead of rendering cyan.
Root cause:
CYN='οΏ½[0;36m' # WRONG: single quotes = literal characters, no escape processing
CYN=$'οΏ½[0;36m' # CORRECT: ANSI-C quoting = ESC byte stored in variableSingle-quoted strings in bash are completely literal. No escape sequences are processed. The variable CYN contained the string οΏ½[0;36m β ten ASCII characters β not an ESC byte followed by [0;36m.
$'οΏ½' uses ANSI-C quoting, which processes οΏ½ as octal escape β ESC byte (0x1B) at assignment time.
Why subtle: echo -e 'οΏ½[0;36m' renders correctly because -e processes escape sequences. But printf "${CYN}text${RST}" without -e does not re-process the variable content. The distinction between "the variable contains an escape sequence as text" vs "the variable IS the escape byte" is not obvious from syntax inspection.
Symptom: The install.ps1 script terminated during cargo build with a PowerShell TerminatingError even though the build would have succeeded. First reported by Wayne.
Root cause: Three settings in combination:
$ErrorActionPreference = "Stop"β terminate on any error2>&1β redirect stderr to stdoutcargowrites ALL progress to stderr
Under $ErrorActionPreference = "Stop", any object written to the error stream terminates the script. 2>&1 causes PowerShell to wrap stderr output in ErrorRecord objects. Every "Compiling foo v1.0.0" progress line from cargo became a terminating error.
Fix: Remove all three. Drop $ErrorActionPreference = "Stop", remove 2>&1, check $LASTEXITCODE after the build.
The lesson: $ErrorActionPreference = "Stop" is correct for scripts that only call PowerShell cmdlets. It fails for scripts that invoke native executables (cargo, git, npm, docker) that legitimately write to stderr during normal operation.
Not a shipped bug β a design decision that prevents one.
Every time a field is added to Config, existing users have a JSON config without that field. Without #[serde(default)], serde_json fails to deserialise on the next update with "missing field" β breaking every existing installation.
The fix: all fields annotated with #[serde(default)] from the start:
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)] pub api_key: String,
#[serde(default)] pub model: String,
#[serde(default)] pub backend: String,
#[serde(default)] pub telemetry_share_central: bool,
#[serde(default)] pub sessions_since_telemetry_prompt: u32,
// ... every field
}The rule: annotate config fields with #[serde(default)] at the point of addition. Retroactive annotation requires a re-release. If you ship without it, the next !update sends every existing user into a broken state with no automatic recovery path.
mang.sh was designed and built entirely in collaboration with Perplexity Computer β from initial architecture through every feature, bug fix, and documentation pass.
Paul provided:
- Product judgment β what the tool should feel like, what features matter, when something is good enough
- Domain knowledge β how CLI tools actually behave, what shell quirks exist, what users realistically do
- Quality bar β what "done" means, when to push for more depth, when to ship
- The bug reports β actual observed failures that drove fixes
Perplexity Computer provided:
- Implementation speed β a working Rust module in minutes rather than hours
- Rust idiom knowledge β knowing
#[serde(default)]exists, thatdirsresolves config paths correctly, thatrustylineis the right readline library, thatreqwest::blockingis the right call for a CLI - Debugging breadth β holding the entire codebase in context when diagnosing a multi-file bug
- Documentation completeness β writing this README in full
The result: 13 versions from v1.0.0 to v3.0.3 in a matter of days. The code compiles, has zero clippy warnings, handles edge cases properly. The architecture decisions reflect real trade-offs between binary size, compile time, correctness, and maintainability.
It looks like a very good senior engineer who responds instantly. You describe what you want β "add a background version check that doesn't block the startup prompt" β and you get back a full implementation: a thread, a rate-limiting file, a join handle stored in the right Vec, the UI message integrated into the REPL flow.
Then you test it. You find edge cases: "what happens if the network is unavailable?" "What happens if the rate-limit file is corrupted?" You describe the gap. You get a fix.
What it does NOT look like: blindly accepting every output. Every suggestion went through cargo check, cargo clippy, and manual review. The detached-thread telemetry bug was not caught by the AI β it was caught by noticing the JSONBin collection was empty after real use. The uninstall stdin bug was caught by a user report. AI-generated code must be compiled, run, and used.
Because honesty about how software is built matters. Open-source lives and dies by trust. The architecture decisions are human. The product taste is human. The implementation was AI-assisted, and that assistance was substantial. Presenting this as entirely human-written would be a quiet lie.
The model: AI as force multiplier. The human makes the meaningful decisions. The AI removes the mechanical barriers between intention and implementation.
This section documents real technical decisions, dead ends, and the reasoning behind them. It's meant to be genuinely useful if you're building something similar β not a highlight reel.
A blocking single-threaded REPL is the right model for a CLI tool.
The entire mang.sh main loop is synchronous. There is exactly one network call per turn and it blocks. The alternative β async with tokio β would add ~200 KB to the binary, 30 seconds to the compile time, and zero user-visible benefit at this call frequency. One request per second doesn't need an async runtime. async/await is for servers handling hundreds of concurrent connections, not for CLI tools where the user is the bottleneck.
rustyline gives you readline for free β use it.
The REPL prompt needs arrow-key editing, Ctrl-W word delete, and in-session history (β/β). Rolling your own input handling is a week of work. rustyline is battle-tested, supports all of these, and integrates in five lines. The add_history_entry call keeps session history without touching the shell's history file (which is what history.rs is for β a separate, intentional concern).
The context window is a rolling buffer, not a log.
Multi-turn context (follow-up prompts like "now do the same for /tmp") works by injecting prior prompt/command pairs as user/assistant turns in the messages array. The window is bounded β oldest turns evicted first β to prevent unbounded token growth. Default is 5 turns. The key insight: the AI doesn't need the full session history, just the last few turns to resolve pronouns and relative references.
State the output format twice. Really. LLMs attenuate instructions that appeared many tokens ago. Stating the JSON schema once at the top and repeating it in a concrete example dramatically improves compliance, especially on smaller models (tested on Llama 3.2 7B and Mistral 7B where compliance dropped from ~90% to ~60% with single-statement format rules).
Structured output is load-bearing, not cosmetic.
The JSON envelope ({ "commands": [...], "explanation": "..." }) is the foundation everything else depends on. Without it, you have to parse freeform text β which means regex, which means edge cases, which means failures. Invest in the system prompt first. The entire suggestion pipeline, command display, execution, history recording, and telemetry all hang off clean parsing.
Temperature 0.2 is the sweet spot for command generation.
Full greedy decoding (temperature=0) produces too-literal outputs and gets stuck on ambiguous prompts. Temperature 0.5+ introduces hallucinated flags. 0.2 picks the most-probable conventional command form while handling natural language variation. Tested across GPT-4o-mini, Claude 3 Haiku, Llama 3.2, Mistral β 0.2 is consistently correct.
Platform context beats tool inventories.
OS=macos SHELL=zsh syntax=posix in every prompt is worth more than a paragraph listing available tools. The model infers brew, open, pbcopy, arm64 paths from the OS. The syntax=posix / syntax=powershell / syntax=cmd field is the single highest-leverage addition β it tells the model exactly which shell syntax family to use, eliminating the most common cross-platform errors (PowerShell 5 doesn't support &&; cmd.exe uses %VAR% not $VAR).
When the tool can't understand you, the tool should ask β not just fail.
The original empty-suggestion path printed a message and returned to a blank prompt. Users were left stranded. The wizard (v3.0.2) converted a dead end into a guided recovery. The prompt coach uses temperature=0.5 for natural-sounding questions. Synthesis is pure string concatenation (no extra AI call) β the downstream suggest_commands() handles disambiguation from rich compound context.
"Prompt coaching" and "command generation" are different modes.
Same backend, completely different system prompts. Command generation: temperature=0.2, strict JSON, deterministic. Coaching: temperature=0.5, plain prose, conversational. Separating them into suggest_commands() vs suggest_raw() keeps both contracts clean and makes each easy to reason about.
State your output format twice. LLMs attenuate instructions that appeared many tokens ago. Stating the JSON schema once at the top and again in a concrete example dramatically improves compliance, especially on smaller models.
Structured output is load-bearing, not cosmetic. The JSON envelope is the foundation everything else depends on. Without it, command extraction is unreliable and the entire pipeline breaks. Invest in the system prompt first.
Temperature 0.2 > temperature 0 for tool use. Full greedy decoding (temp=0) produces too-literal, sometimes stuck outputs. 0.2 handles paraphrasing and unusual prompts without breaking format compliance.
Context window position matters. The most recent message gets the most attention. Important constraints (like the JSON schema) are best restated near the end of the prompt for smaller models.
Platform context beats tool inventories. OS=macos SHELL=zsh syntax=posix is more useful than telling the model "you have access to brew, apt, etc." Let the model infer the tools from the environment.
cargo check is your fastest feedback loop.
Type-checks the entire project in seconds without a full build. Use it after every change. cargo build --release only when you need the actual binary. The full release build takes 60β90 seconds on first run; cargo check takes under 2 seconds. That's the difference between a tight iteration loop and a frustrating one.
cargo clippy catches what the type checker misses.
Clippy is not just style β it catches logic bugs. is_some_and() vs map_or(), is_empty() vs len() == 0, iterator chains that allocate unnecessarily. Running with -D warnings (deny all warnings as errors) enforces a clean codebase across every commit. mang.sh has maintained zero clippy warnings since v2.3.3.
#[serde(default)] on every config field β without exception.
This is the key to forward-compatible config files. Every new field added to Config must have #[serde(default)] so existing users' JSON configs load without error after an update. Forgetting this once breaks every existing installation on the next !update. The lesson: annotate new fields defensively at the point of addition, not retroactively when the bug surfaces in prod.
Box<dyn Error> early, custom error types later.
For a project of this size, Box<dyn std::error::Error> as a return type lets you use ? on any error type β reqwest, serde_json, std::io, all work without defining a custom enum. The right starting point. Add typed errors only when you need to pattern-match on variants (e.g. network timeout vs auth failure). Don't prematurely optimise the error path.
Stdio::inherit() for any child process β always.
Capturing stdout/stderr from a child process breaks: interactive programs (vim, htop, ssh), streaming output (cargo build, docker logs), colour-aware tools (ls --color, grep --color), and any program that detects whether it's writing to a TTY. The correct approach: Stdio::inherit() for all three streams, let the child own the terminal directly.
Store JoinHandles; never detach background threads.
A dropped JoinHandle silently detaches the thread. When main() returns, all detached threads are killed immediately β before any pending I/O completes. mang.sh v2.3.1 shipped with this bug: every telemetry entry was silently lost because the HTTP POST thread was killed on process exit. Fix: Vec<JoinHandle<()>> stored in main(), joined at every exit path (Ctrl-D, Ctrl-C, !exit, readline error β all four).
Blocking reqwest is the right call for a CLI tool.
reqwest::blocking is synchronous, has a clean API, handles redirects and TLS, and doesn't require tokio. For a tool that makes one HTTP request per user turn β where the user is already staring at a "Thinkingβ¦" spinner β async adds compile overhead and binary size for zero UX benefit. Reserve async/await for servers and high-concurrency situations.
The dirs crate for config paths β always use it.
Hard-coding ~/.config/ breaks on macOS (Application Support), Windows (AppData\Roaming), and any system with a non-standard $XDG_CONFIG_HOME. The dirs crate resolves the OS-correct path automatically and uses the Cargo package name (mang) as the subdirectory. Changing the package name in Cargo.toml automatically moves the config directory β seamless rebrand.
regex::Regex should be compiled once, not per call.
The intent detection patterns in ai.rs compile a Regex from a string on every intent_is_api_change() call. For a REPL that runs this on every input, this is a small but unnecessary allocation. Production pattern: use once_cell::sync::Lazy<Regex> or regex::RegexSet compiled at startup. The current implementation is correct and cheap enough at this scale β but worth knowing for higher-frequency applications.
Shell detection is harder than it looks.
$SHELL gives you /bin/zsh or /usr/bin/bash on Unix β helpful but not precise. On Windows it's empty. In a Git Bash session on Windows, $SHELL is /usr/bin/bash but you're actually inside Git Bash running on Win32. The correct approach: check multiple env vars ($SHELL, $ZSH_VERSION, $BASH_VERSION, $FISH_VERSION), check the parent process name on Windows, and maintain an explicit ShellKind enum covering zsh, bash, fish, sh, PowerShell 5, PowerShell 7, cmd.exe, Git Bash, and Unknown. The shell matrix (8 variants Γ 3 OS families) sounds complex but each variant is a simple string match.
The syntax= hint is the highest-leverage AI context field.
Telling the AI syntax=powershell5 (not syntax=powershell) means it knows to avoid && (unsupported in PS5) and use ; or -and instead. syntax=cmd means %VAR% paths and & chaining instead of POSIX. Without this, the AI generates technically correct commands for the wrong shell β they look right but fail silently or with confusing errors.
curl is not curl in PowerShell.
PowerShell has a built-in alias curl β Invoke-WebRequest that silently ignores -fsSL. The Unix idiom curl -fsSL https://... | bash fails in PowerShell with no useful error message. The correct idiom: iwr -useb <url> | iex. Document this at every install instruction and provide a separate .ps1 installer that doesn't use curl at all.
PowerShell 5 vs 7 is a real distinction.
PS5 (built into every Windows installation) doesn't support && for command chaining. PS7 does. Detecting the version and emitting syntax=powershell5 vs syntax=powershell7 to the AI context prevents an entire class of confusing "command not found" errors for Windows users. They both look like "PowerShell" but behave differently.
$ErrorActionPreference = "Stop" + 2>&1 + native commands = script death.
This triple combination in an installer script kills cargo build β cargo writes all progress output to stderr, which PowerShell's Stop preference interprets as a terminating error even when the build succeeds. Fix: remove $ErrorActionPreference = "Stop", remove 2>&1, let cargo output flow freely, and check $LASTEXITCODE after. v2.2.0 fixed this after a bug report from a Windows user whose PS5.1 install script was killing on "Compiling foo v1.0.0".
Fire-and-forget is wrong for anything that must complete.
The v2.3.0 implementation used std::thread::spawn() and immediately dropped the JoinHandle. The thread was detached. The process exited. The HTTP POST was killed at the OS level before any bytes left the machine. Every single telemetry entry was silently lost for an entire release. The fix is mechanical but critical: store every JoinHandle in a Vec, join all of them at every exit path. There are exactly four exit paths in mang.sh's main loop (Ctrl-D, Ctrl-C, readline error, !exit) β every one must join.
Silent error swallowing makes debugging impossible.
Err(_) => {} is a legitimate production choice for non-critical background work. But during development it's a blackout. MANGDEBUG=1 yo enables a mode that prints the full JSON payload and HTTP response to stderr before submitting. This is how we diagnosed the dropped-thread bug β without it, the only symptom was "no data in the collection". Always add an escape hatch that makes the invisible visible.
Write-only API keys are the correct security model for embedded credentials. The telemetry write key is embedded in the mang.sh binary. It can only create new JSONBin bins (POST /b) β it cannot read, update, or delete anything. The worst-case scenario if the key leaks: someone adds junk entries to a collection that one person reads once a week. Compare to: a read key that exposes every user's command history. The security model maps directly to the threat model. A write-only key ships. A read key never ships.
Opt-in with periodic gentle reminders is better than opt-out.
Community telemetry is off by default in mang.sh. A counter in config triggers a reminder every 10 sessions. Users who want to contribute opt in with !feedback on. This approach: collects meaningful signal from genuinely interested users (not passive non-rejection), generates no resentment, and keeps the README's "never shared" claims credible. The alternative (opt-out) increases the data volume but degrades the signal quality and trust.
curl | bash is controversial but practical.
Security-conscious developers object to piping untrusted scripts to a shell. The correct response: serve the script over HTTPS (TLS validates the server identity), pin the SHA-256 if paranoid, and always provide a manual install option (clone + cargo build --release). The curl | bash pattern is the dominant convention for developer tools precisely because it works on every Unix system with zero prerequisites β which matters enormously for a tool that installs Rust as part of its own installation.
/dev/tty is required for interactive prompts in piped scripts.
When a script runs via curl | bash, its stdin is the pipe (the script content), not the terminal. Any read call reads from the pipe, not from the user's keyboard β it returns EOF immediately and the script proceeds with an empty answer. This caused the v1.1.2 uninstall script to always report "Cancelled" regardless of what the user typed. Fix: read -r reply </dev/tty forces the read from the actual terminal. This is not widely documented and has caused bugs in many prominent open-source installers.
Shell colour codes must use ANSI-C quoting.
CYN='οΏ½[0;36m' stores a literal backslash + 0 + 3 + 3 β not an escape byte. The terminal sees οΏ½[0;36m as printable text and renders the raw escape sequence instead of colour. The correct form: CYN=$'οΏ½[0;36m' (ANSI-C quoting) stores the actual ESC byte at assignment time. v2.3.4 fixed this after a bug report about the update script printing literal οΏ½ sequences instead of colours.
Idempotent installers are non-negotiable.
Running the installer twice must produce the same result as running it once. This means: detect existing binary and reinstall in place, detect existing aliases and skip writing them again, never corrupt the user's shell rc. The mang.sh installer checks for an existing yo binary at the start and uses its directory as the install target for reinstalls β preserving any non-standard install locations.
AI pair programming is a force multiplier, not a replacement. mang.sh was built entirely in collaboration with Perplexity Computer (Claude Sonnet + GPT-4o). The architecture decisions, design trade-offs, and final judgements are human. The implementation speed β 13 versions from v1.0.0 to v3.0.3 in a few days β would have taken weeks solo. The right mental model: AI handles the "how to write this in idiomatic Rust" while the human handles "what should this actually do and why".
AI-generated code must be compiled and tested, not trusted.
Every AI suggestion for this project went through cargo check, cargo clippy, and manual review before committing. Several suggestions had subtle bugs: dropped JoinHandles (the telemetry issue), wrong PowerShell quoting, incorrect serde attribute placement. The AI is a skilled colleague who works fast and occasionally makes mistakes β treat it accordingly.
Document the "why" aggressively. AI-generated code is often correct but context-free. Adding the comment blocks explaining why a design decision was made β not just what it does β is entirely the human's responsibility. Future maintainers (including future-you working with AI) need the reasoning, not just the implementation.
mang.sh can optionally share anonymised data to improve the AI system prompt. Reviewed weekly by Paul Fleury.
Shared (opt-in, default OFF):
| Field | Example |
|---|---|
| Prompt | "find log files older than 7 days" |
| Commands | ["find . -name '*.log' -mtime +7"] |
| Model | "openai/gpt-4o-mini" |
| OS + shell | "macos" + "zsh" |
| Worked | true |
| Version | "v3.0.4" |
| Timestamp | "2026-03-23T12:00:00Z" |
Never shared: API keys, file paths, CWD, command output, username, hostname.
Enable: !feedback on Β· Disable: !feedback off Β· Full settings: !feedback Β· Live test: !feedback test
Debug mode: MANGDEBUG=1 yo
| Model | Cost | Best for |
|---|---|---|
openai/gpt-4o-mini |
~$0.15/1M tokens | β Default β fast, reliable |
openai/gpt-4o |
~$2.50/1M tokens | Complex multi-step requests |
anthropic/claude-3.5-sonnet |
~$3/1M tokens | Tricky, context-heavy tasks |
anthropic/claude-3-haiku |
~$0.25/1M tokens | Speed-critical workflows |
meta-llama/llama-3.3-70b-instruct:free |
Free | Getting started |
Get a key: openrouter.ai/keys
Full history: CHANGELOG.md
- πͺͺ Credits screen (
!credits/!cr) β author info, project links, build stack, mythology - π README: deep technical expansion β REPL architecture, module reference, design rationale, toolchain notes
- π§ Advanced Prompt Mode (
!prompt/!p) β up to 3 AI-generated clarifying questions when you're stuck; auto-triggers when the AI returns no commands
- π¨ Redesigned banner: clean block-letter
MANG(cyan) +.sh(bold white),ε₯θ Β· Gou Mang Β· Spirit Messengerheader, minimal dim frame - π§ Uninstall script: auto-removes legacy
yo-rustconfig directories and aliases
- ποΈ Rebranded from Yo, Rust! to mang.sh β Gou Mang (ε₯θ), spirit messenger
- π New home: mang.sh Β· install:
curl -fsSL https://mang.sh/install | bash - π JSONBin collection renamed to
mang-sh-telemetry - π Zero remaining references to the old name anywhere in the codebase
- π Background update check on every launch Β·
!update/!checkshortcuts - π N on suggestion = iterative refinement tunnel
- π Shell colour variables fixed (
$'\033'ANSI-C quoting)
git checkout -b feat/your-feature
git commit -m 'feat: describe your change'
git push origin feat/your-feature
# β open a Pull Request at github.com/paulfxyz/mangIdeas on the list:
--stop-on-errorflag for multi-command sequences- Keychain/credential manager storage for the API key
yo --versionchecking againstmang.sh/version(lightweight text endpoint)
MIT β see LICENSE.
Made with β€οΈ by Paul Fleury β built in collaboration with Perplexity Computer.
- π paulfleury.com
- π linkedin.com/in/paulfxyz
- π @paulfxyz
- π§ hello@paulfleury.com
I want to be upfront about something.
I am not a software engineer. I'm not a coder in the traditional sense, and I make no claim to being one. I'm a former hacker turned entrepreneur β someone who spent a decade in intelligence, then pivoted to building things on the internet, and who has always been more comfortable with a terminal than a corporate job.
This entire project β every line of Rust, every shell script, every installer, every i18n translation, every word of this README β was built through vibe coding: a continuous, conversational collaboration with AI tools, primarily Claude and Perplexity (in particular, with agentic tasks through Perplexity Computer). I described what I wanted. I pushed back when something was wrong. I had opinions about architecture, UX, naming, tone; sometimes a more technical comment about a stack, an approach or a sequence of code. The AI handled the implementation.
The ideas are mine. The judgment calls are mine. The product instincts are mine. The code was written by AI under my direction.
I think this is worth saying explicitly, for a few reasons:
Honesty. Open-source projects are built on trust. You should know what you're looking at.
It's genuinely impressive what's possible. mang.sh went from zero to 13 versions β including a full rebrand, multi-platform support, an AI wizard, a telemetry system, a website in 11 languages, and a properly documented codebase β in a matter of days. That timeline is only possible with AI assistance. Pretending otherwise would undersell what these tools can do.
The future is already here. If you're a founder, a hacker, a product person who always had ideas but not enough engineering bandwidth β this is for you. You don't need to be a software engineer to build software anymore. You need taste, judgment, a clear head, and a good working relationship with AI.
That's the only thing I'm actually claiming to have.
ε₯θ β the messenger between heaven and earth, between intent and command.
β If mang.sh saved you time, drop a star β it helps others find it. β