Back to Blog

Run Shell Commands in Tauri Apps | Rust Process Spawning Tutorial

Sandy LaneSandy Lane

Video: Run Shell Commands in Tauri Apps | Rust Process Spawning Tutorial by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

tauri-plugin-shell lets your Tauri app spawn external processes — git, ffmpeg, your own CLI tools — and read their output.

Sometimes the right answer is "shell out." Calling git, ffmpeg, ImageMagick, or any other command-line tool from your Tauri app is the path of least resistance for many features. The shell plugin makes it safe and structured.

Install

cargo add tauri-plugin-shell
npm install @tauri-apps/plugin-shell

Register:

.plugin(tauri_plugin_shell::init())

Capabilities and scope

The shell plugin requires explicit allowlisting of which commands you can run. This is a security boundary — you don't want a malicious script to call rm -rf /.

src-tauri/capabilities/default.json:

{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "name": "git",
          "cmd": "git",
          "args": true
        },
        {
          "name": "ls",
          "cmd": "ls",
          "args": ["-la", "-l", "-a"]
        }
      ]
    }
  ]
}

name is what your code references; cmd is the actual binary; args controls what arguments are allowed (true = any, an array = only these specific args).

This narrow scoping is the feature. Tauri's shell plugin won't run an arbitrary command — only the ones you whitelist. The user can't trick the app into running something the developer didn't intend.

Running a command from the frontend

import { Command } from "@tauri-apps/plugin-shell";

const command = Command.create("git", ["status", "--porcelain"]);
const output = await command.execute();

console.log("stdout:", output.stdout);
console.log("stderr:", output.stderr);
console.log("code:", output.code);

Command.create(name, args) builds a command (using the name from your allowlist, not the actual binary path). .execute() returns the full output once the command exits.

The output object has:

  • stdout: the standard output as a string.
  • stderr: error output.
  • code: the exit code (0 for success, non-zero for failure).
  • signal: the signal that killed the process (usually null).

Streaming output

For long-running commands, you don't want to wait for the whole output. Use spawn and listen for events:

const command = Command.create("npm", ["install"]);

command.stdout.on("data", (line) => console.log("stdout:", line));
command.stderr.on("data", (line) => console.error("stderr:", line));
command.on("close", (data) => console.log("exit:", data.code));

const child = await command.spawn();

// to kill it:
// await child.kill();

Each line of output fires the data event as it arrives. Useful for build tools, package installs, anything where the user benefits from live feedback.

Running from Rust

use tauri_plugin_shell::ShellExt;

#[tauri::command]
async fn git_status(app: tauri::AppHandle) -> Result<String, String> {
  let output = app.shell()
    .command("git")
    .args(["status", "--porcelain"])
    .output()
    .await
    .map_err(|e| e.to_string())?;

  let stdout = String::from_utf8_lossy(&output.stdout).to_string();
  Ok(stdout)
}

When to do it from Rust:

  • The output needs Rust-side processing before the frontend cares.
  • You want to wrap the command in better error handling.
  • The command needs to be run non-interactively with a specific environment.

Working directories

const command = Command.create("git", ["log", "--oneline", "-5"], {
  cwd: "/path/to/repo",
});

Or in Rust:

app.shell().command("git").args(["log"]).current_dir("/path/to/repo")...

Environment variables

const command = Command.create("my-tool", ["--input", "data.txt"], {
  env: { LOG_LEVEL: "debug" },
});

The default behaviour is to clear environment variables for security. To inherit the parent process env, pass clearEnv: false.

A practical example: git status panel

function GitStatus({ repoPath }: { repoPath: string }) {
  const [files, setFiles] = useState<string[]>([]);

  async function refresh() {
    const cmd = Command.create("git", ["status", "--porcelain"], { cwd: repoPath });
    const out = await cmd.execute();
    setFiles(out.stdout.split("\n").filter(line => line.trim()));
  }

  useEffect(() => { refresh(); }, [repoPath]);

  return (
    <ul>
      {files.map((line, i) => <li key={i}><code>{line}</code></li>)}
    </ul>
  );
}

A panel that shows the working-tree status of a git repo. Refresh on demand. The full output appears as a list of lines.

For something fancier — a "Commit" button that runs git add and git commit — chain commands or use a single Rust command that runs them in sequence.

Sidecar binaries

If your app ships with an external binary (a Python script bundled with the app, an FFmpeg binary, etc.), Tauri supports sidecars — bundled executables that ship with the app:

"bundle": {
  "externalBin": ["binaries/ffmpeg"]
}

Then in code:

const command = Command.sidecar("binaries/ffmpeg", ["-i", "input.mp4", "output.webm"]);

The plugin resolves the path to the bundled binary correctly across platforms.

Killing a long-running command

const child = await command.spawn();
// ... user clicks Cancel
await child.kill();

Important for build tools, transcoders, anything where the user might want to bail out.

Common mistakes

Forgetting to whitelist. shell:allow-execute capability isn't granted; the call fails with an ACL error.

Running shell commands with user-supplied input. Even with the allowlist, passing user-controlled strings as args can be risky if the args list is true (any args allowed). Sanitise or use a strict allowlist.

Reading stderr only on failure. Some tools write progress to stderr (e.g., curl, ffmpeg). Don't ignore stderr.

Forgetting cwd. Commands run in your app's working directory by default — usually not what you want for git or build tools.

Not handling code != 0. A non-zero exit means the command failed. Check output.code and surface the error.

What's next

Next lesson: error handling with thiserror. Building proper error types in your Rust commands so failures travel cleanly to the frontend.

Recap

tauri-plugin-shell for spawning external processes. Allowlist commands explicitly in capabilities. Command.execute() for one-shot runs, Command.spawn() plus event listeners for streaming. Pass cwd and env per command. Use sidecars for bundled binaries. Always check the exit code.

Next: error handling.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.