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 runoptions(table, optional):env(table) - Extra environment variables.PATHis appended to the default PATH; every other variable replaces it
Returns: table
code(number) - Exit code (0 means success)stdout(string) - Standard outputstderr(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 runoptions(table, optional) - Same shape asexecute(); supportsenv
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;-1when terminated bycancel())stdout(string) - Standard outputstderr(string) - Standard error, separate fromstdout, same semantics asexecute()
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 returnsnil. 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
- Commands run through
/bin/bash -c, so bash features are available - Timeout: 60 seconds by default
- Paths: file paths may contain spaces — quote them, or use
app.shell.quote() - Return value: always check
result.codeto tell whether the command succeeded - 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'"