app.shell - Shell Command API

Run shell commands. Calls made inside a coroutine go async automatically.

Permission: L2 Sensitive — declared; warns the user at install time ("shell")

Methods

app.shell.execute(command, options?)

Runs a shell command synchronously.

Parameters:

  • command (string) - The command to run
  • options (table, optional):
    • env (table) - Extra environment variables. PATH is appended to the default PATH; every other variable replaces it

Returns: table

  • code (number) - Exit code (0 means success)
  • stdout (string) - Standard output
  • stderr (string) - Standard error. It is a separate pipe from stdout, so anything the command writes to stderr appears only here
-- A simple command
local result = app.shell.execute("ls -la")
if result.code == 0 then
    app.log.info(result.stdout)
else
    app.log.error(result.stdout)
end

-- Run a script
local result = app.shell.execute("python3 /path/to/script.py")

-- Capture command output
local result = app.shell.execute("which git")
local gitPath = result.stdout:gsub("\n", "")

Automatically async inside coroutines: called from a coroutine, it yields and runs in the background, transparently to the caller.

-- Called inside a coroutine
app.thread.create(function()
    -- Runs async automatically; the main thread is never blocked
    local result = app.shell.execute("sleep 5 && echo done")
    app.log.info(result.stdout)
end)

app.shell.start(command, options?)

Starts a command and returns a task handle immediately, without blocking. Unlike executeAsync, you can read the result and cancel the command while it runs.

Parameters:

  • command (string) - The command to run
  • options (table, optional) - Same shape as execute(); supports env

Returns: table - A task handle with three methods:

Method Returns Description
task:isDone() boolean Whether the command has finished
task:result() table or nil The result — readable only once (see below)
task:cancel() boolean Terminates the command

The table returned by task:result():

  • code (number) - Exit code (0 means success; -1 when terminated by cancel())
  • stdout (string) - Standard output
  • stderr (string) - Standard error, separate from stdout, same semantics as execute()
local task = app.shell.start("find / -name '*.log' 2>/dev/null")

-- Poll for completion
local timer
timer = app.timer.start(0.5, function()
    if not task:isDone() then return end

    local r = task:result()
    if r.code == 0 then
        app.log.info(r.stdout)
    else
        app.log.error("Failed: " .. r.stderr)
    end
    timer:stop()
end)

result() consumes the result. The first call hands it over and drops it from the internal table; a second call returns nil. Store it if you need it more than once:

local r = task:result()   -- keep it
if r then
    useIt(r.stdout)
    useAgain(r.code)      -- use the variable, do not call task:result() again
end

Cancelling a task that has not finished:

if not task:isDone() then
    task:cancel()
    -- task:result() now returns { code = -1, stdout = "", stderr = "Cancelled" }
end

Which one to use:

What you need Use
The result, inside a coroutine (blocking is fine) execute() — auto-yields to the background, transparently
The result without blocking, or the ability to cancel start()
Fire and forget start(), then simply ignore the handle

Notes

  1. Commands run through /bin/bash -c, so bash features are available
  2. Timeout: 60 seconds by default
  3. Paths: file paths may contain spaces — quote them, or use app.shell.quote()
  4. Return value: always check result.code to tell whether the command succeeded
  5. Encoding: output is UTF-8

Examples

Calling an external tool

function MyPlugin:handleConvert(context)
    -- Check whether the tool is installed
    local result = app.shell.execute("which convert")
    if result.code ~= 0 then
        app.dialog.alert({title = "Error", message = "Please install ImageMagick first"})
        return
    end

    local files = context.selectedFiles
    local p = app.progress.create({title = "Converting", indeterminate = false})

    for i, file in ipairs(files) do
        p:update(i * 100 / #files)

        local output = app.path.withExtension(file, "png")
        -- Quote the paths so spaces are handled
        local cmd = string.format('convert "%s" "%s"', file, output)

        local result = app.shell.execute(cmd)
        if result.code ~= 0 then
            app.log.error("Conversion failed: " .. result.stdout)
        end
    end

    p:close()
    app.notification.show("Done", "Converted " .. #files .. " files")
end

Running git

function MyPlugin:handleGitStatus(context)
    local dir = context.currentDirectory

    -- cd into the directory, then run the command
    local result = app.shell.execute(string.format('cd "%s" && git status --short', dir))

    if result.code ~= 0 then
        app.dialog.alert({title = "Error", message = "Not a git repository"})
        return
    end

    if result.stdout == "" then
        app.dialog.alert({title = "Git status", message = "Working tree clean"})
    else
        app.dialog.alert({title = "Git status", message = result.stdout})
    end
end

Extracting audio with FFmpeg

function MyPlugin:handleExtractAudio(context)
    -- Check for ffmpeg
    local result = app.shell.execute("which ffmpeg")
    if result.code ~= 0 then
        app.dialog.alert({title = "Error", message = "Please install ffmpeg first"})
        return
    end

    for _, video in ipairs(context.selectedFiles) do
        local audio = app.path.withExtension(video, "mp3")

        local cmd = string.format(
            'ffmpeg -i "%s" -vn -acodec libmp3lame -q:a 2 "%s"',
            video, audio
        )

        local result = app.shell.execute(cmd)
        if result.code == 0 then
            app.log.info("Extracted: " .. audio)
        else
            app.log.error("Extraction failed: " .. result.stdout)
        end
    end
end

Running a long task in the background

function MyPlugin:handleBackgroundTask(context)
    local script = context.selectedFiles[1]
    if not script then return end

    -- Run the script in the background
    app.shell.start(string.format('"%s" > /tmp/output.log 2>&1', script))

    app.notification.show("Started", "The script is running in the background")
end

Reading system information

function MyPlugin:getSystemInfo()
    local info = {}

    -- macOS version
    local result = app.shell.execute("sw_vers -productVersion")
    if result.code == 0 then
        info.osVersion = result.stdout:gsub("\n", "")
    end

    -- CPU
    result = app.shell.execute("sysctl -n machdep.cpu.brand_string")
    if result.code == 0 then
        info.cpu = result.stdout:gsub("\n", "")
    end

    -- Memory size
    result = app.shell.execute("sysctl -n hw.memsize")
    if result.code == 0 then
        local bytes = tonumber(result.stdout)
        if bytes then
            info.memory = string.format("%.1f GB", bytes / 1024 / 1024 / 1024)
        end
    end

    return info
end

Processing command output

function MyPlugin:parseOutput(command)
    local result = app.shell.execute(command)
    if result.code ~= 0 then
        return nil, result.stdout
    end

    -- Split the output into lines
    local lines = {}
    for line in result.stdout:gmatch("[^\n]+") do
        table.insert(lines, line)
    end

    return lines, nil
end

function MyPlugin:handleListProcesses(context)
    local lines, err = self:parseOutput("ps aux | head -20")
    if err then
        app.log.error(err)
        return
    end

    app.dialog.alert({
        title = "Processes",
        message = table.concat(lines, "\n")
    })
end

Utilities

app.shell.quote(str)

Escapes a string with POSIX single quotes so it can be safely concatenated into a shell command.

Parameters:

  • str (string) — The string to escape

Returns: string — The escaped string, wrapped in single quotes

-- Safely handle paths with spaces or special characters
local cmd = "ls -la " .. app.shell.quote("/path/to/my file.txt")
app.shell.execute(cmd)

-- A path containing a single quote
app.shell.quote("it's a file")  -- "'it'\\''s a file'"
Developer Documentation
User Guide
Getting Started Menu Editor Guide Plugin Manager Settings Script Menus FAQ
Script Development
Development Guide
Plugin Development
Quick Start Development Guide Example Plugins
API Reference
Overview API Query Plugin Info Logging Finder Context Plugin Settings Internationalization
UI & Interaction
Dialog Progress Notification Chooser WebView Status Bar Dock
Files & Paths
File Operations Path Utilities Finder Actions Trash Extended Attributes Metadata File Watcher
Data Formats
JSON Plist CSV XML PDF Image
Text & Encoding
String Regex Date & Time Color Crypto
System
Shell Commands Process Application System Info AppleScript Shortcuts
System Info
Network Power/Battery Screen/Appearance Audio Bluetooth Location
Network
HTTP WebSocket URL
Input & Clipboard
Keyboard Mouse Hotkey Clipboard Window
Storage
SQLite Keychain UserDefaults
Media
OCR QR Code
Utilities
Archive UTI Share Timer Wake Lock Thread