substrio_shell supports commands such as grep -rn and sed -n 10,40p. It is the source.shell operation (POST /v1/_source/shell, MCP tool substrio_shell).
There is no process or access to the host filesystem. The server interprets a POSIX shell subset, and each command is a builtin operating on the app's stores. Paths cannot use .. to leave a mount. Symlinks, devices, and network access are unavailable.
Mounts
| Path | What it is | Writable |
|---|---|---|
/src |
The branch's tree (branch, default main): the same files source.tree lists |
yes: every change of one call lands as one commit |
/site |
The site/ folder of the version live runs |
no: SHELL_READ_ONLY |
/files |
The app's stored files (file.list) |
yes: each write is a file.upload; rm is refused (SHELL_FILES_DELETE), file.delete does it |
A command starts in /src (or cwd). Any other path, / itself included, is refused with SHELL_PATH_OUTSIDE.
One call, one commit
- Reads see the stores as they are, and every write of the call is staged: a later command in the same call reads what an earlier one wrote (
sed -i ... && grep ...sees the edit). - When the whole command exits with status 0, every
/srcchange is committed together on the branch. The commit message comes frommessage,commit -m, or the command itself, in that order. Each/fileswrite is then stored. - Any other exit status writes nothing. A refusal ends the call immediately and writes nothing.
- A command that changes nothing (a
sed -ithat matched nothing) makes no commit. - Nothing is kept between calls: no variables, no working directory, no history. Each call stands alone.
The call
{"command": "grep -rn 'TODO' /src", "branch": "main", "cwd": "/src", "offset": 0, "message": "..."}
| Field | Meaning |
|---|---|
command |
The command line (required). |
branch |
The branch /src is (default main; main before its first commit is an empty /src, made by the first commit). Any other branch is made by branch.create. |
cwd |
Where the command starts (default /src). |
offset |
Continue a cut output from this byte (the last answer's next_offset). Refused for a command that writes (SHELL_OFFSET_WRITES): running it again would write twice. |
message |
The commit's message. |
expected |
The version the branch must still be at (64 hex); 409 when it moved. |
The answer
{"stdout": "...", "stderr": "", "exit_code": 0, "offset": 0, "truncated": false, "next_offset": 812,
"commit": {"branch": "main", "version": "<64 hex>", "number": 14, "changed": ["src/a.js", "src/lib/b.js"]},
"files": ["exports/report.csv"]}
commit is present only when the call committed; files appears only when it stored files. exit_code is the last pipeline's status, as in a shell. For example, grep's status 1 means no match and is not a refusal.
When output exceeds the budget, the response contains the first part, truncated: true, and next_offset. Call again with offset: next_offset for the next part. stderr_truncated: true indicates stderr was cut at the same budget.
Budgets
Each is a server setting:
| Flag | Default | Past it |
|---|---|---|
--shell-cpu-ms |
2000 | SHELL_CPU_BUDGET: the call ends and lands nothing (a js script shares the same budget) |
--shell-output-bytes |
1048576 | the answer is cut: truncated and next_offset |
--shell-pipe-bytes |
67108864 | SHELL_PIPE_BUDGET: one stage wrote more than this into the next |
Who may run it
source.shell is a read operation. Anyone allowed to read the app's source can run it, including a caller with a read-only grant. Each write is checked against its corresponding operation: source.edit for /src, or file.upload for /files. A caller without the required grant receives SHELL_WRITE_NOT_GRANTED at the first write, and nothing is written.
Grammar
- Pipes
|, lists;,&&,||, and!before a pipeline. - Redirections
>,>>,<,2>,2>&1,>&2,&>, here-strings<<<and heredocs<<EOF(expanded) and<<'EOF'(literal),<<-strips leading tabs. - Single quotes, double quotes, backslash escapes.
- Globs
*,?and[...]on unquoted words (a name starting with.is matched only by a pattern that starts with.). - Variables, a fixed set:
$PWD,$HOME(/src),$SRC,$SITE,$FILES,$BRANCH,$?. Any other isSHELL_UNKNOWN_VARIABLE. - Not in the grammar (
SHELL_SYNTAX):$(...)and backticks, subshells( ),if,for,while,case, functions,NAME=valueassignments, background&.
Builtins
| Command | Options |
|---|---|
grep |
-r -R -n -l -L -c -i -v -w -x -o -q -s -H -h -a -E -F -G -e PAT -m N -A N -B N -C N --include=GLOB --exclude=GLOB --exclude-dir=GLOB --color=never. A binary file answers Binary file X matches. |
sed |
-n -E -r -s -i[SUFFIX] -e SCRIPT -f FILE; addresses N, $, /re/, \cREc, first~step, a,b, a,+N, !; commands s/// (flags g p N i, & and \1..\9), p P d D q Q = a i c y n N h H g G x { }. -i edits in place in the call (no backup file is written). |
find |
-name -iname -path -ipath -wholename -type f/d -maxdepth -mindepth -empty -true -false -print -delete -exec CMD {} ; -exec CMD {} +, !, -a, -o, ( ). |
xargs |
-n N -I REPL -0 -d DELIM -r. |
cat |
-n. |
head, tail |
-n N, -c N, -N, +N (tail), -q. |
wc |
-l -w -c -m. |
sort |
-r -n -u -f -b -s -k KEYDEF -t SEP -o FILE. |
uniq |
-c -d -u -i. |
cut |
-d DELIM -f LIST -c LIST -b LIST -s. |
tr |
ranges, classes ([:upper:] and the like), escapes; -d -s -c. |
diff |
unified output, -u, -U N, -q. |
ls |
-a -l -R -d -1 -F. |
cd, pwd |
within the mounts. |
mv, cp |
-r; an unchanged file is moved or copied as its stored object (0 bytes copied). |
rm |
-r -f; never a mount itself; never under /files. |
mkdir, touch, tee |
mkdir -p, tee -a. |
echo, printf |
echo -n -e; printf with %s %b %c %d %i %u %x %X %o, widths and precisions, the format reused for extra arguments. |
test, [ |
-e -f -d -s -z -n -r -w -x, = == !=, -eq -ne -lt -le -gt -ge. |
basename, dirname, true, false |
|
commit -m MESSAGE |
names the call's commit. |
js |
a script in the app's own runtime (below). |
An unknown command is refused with SHELL_UNKNOWN_COMMAND, whose detail lists the builtins.
js
js 'SCRIPT' [ARGS], js -e 'SCRIPT', js FILE [ARGS], or js <<'EOF' ... EOF (the heredoc is the script). The script runs in the app's own JavaScript runtime, as a function body with these in scope:
| Name | What it is |
|---|---|
fs |
readFile(path), writeFile(path, data), appendFile, readdir(path), stat(path) (size, isFile(), isDirectory()), exists(path), rm(path), rename(from, to), mkdir(path), each also as ...Sync. Paths are the shell's: relative to the cwd, under the three mounts, with the same refusals. Writes are the call's staged writes. |
require |
require('fs') (or 'node:fs') is fs. |
console |
log and info write stdout, error and warn stderr. |
input |
the command's standard input as a string. |
args |
the arguments after the script. |
A value the script returns is printed. An uncaught error is status 1 with the error on stderr. A refusal inside fs (a path outside the mounts, a write the grant does not reach) refuses the whole call even when the script catches it.
Refusals
| Code | HTTP | When |
|---|---|---|
SHELL_SYNTAX |
400 | the command is not in the grammar above |
SHELL_UNKNOWN_COMMAND |
400 | not a builtin (the detail lists them) |
SHELL_UNKNOWN_VARIABLE |
400 | a variable outside the fixed set |
SHELL_PATH_OUTSIDE |
400 | a path under no mount, .. past a mount, a mount itself removed |
SHELL_READ_ONLY |
400 | a write to /site |
SHELL_WRITE_NOT_GRANTED |
403 | a write the caller's grant does not reach |
SHELL_FILES_DELETE |
403 | rm (or mv out of) /files: use file.delete |
SHELL_OFFSET_WRITES |
400 | offset on a command that writes |
SHELL_CPU_BUDGET |
408 | past --shell-cpu-ms |
SHELL_PIPE_BUDGET |
413 | one stage past --shell-pipe-bytes |
Every refusal carries field: "command" and a detail naming the word, path or budget. Nothing lands on any refusal.
What it costs
Per call: one parse of the command, one head read and one read of what live runs. Each directory is read once per call and each file once (later reads in the call are a hash probe), and every builtin works on a view of those bytes: nothing is copied until the answer is written. A pipe hands one buffer to the next stage (two buffers, reused). A write stages one copy of the new bytes (none for mv or cp of an unchanged file). The commit is one History::commit of every changed unit. A js script takes the app's runtime once per script.
Examples
Find every TODO with line numbers:
{"name": "substrio_shell", "arguments": {"command": "grep -rn TODO /src"}}
Read lines 10 to 40 of a file:
{"name": "substrio_shell", "arguments": {"command": "sed -n 10,40p functions/orders.js"}}
Rename a function across every file, one commit:
{"name": "substrio_shell", "arguments": {"command": "grep -rl getOrders functions | xargs sed -i 's/getOrders/listOrders/g'"}}
Write a new page from a heredoc:
{"name": "substrio_shell", "arguments": {"command": "cat > site/about.html <<'EOF'\n<h1>About</h1>\n<p>We make todo lists.</p>\nEOF"}}
Transform a file with a script:
{"name": "substrio_shell", "arguments": {"command": "js 'const rows = JSON.parse(fs.readFile(\"data/todos.json\")); fs.writeFile(\"data/todos.json\", JSON.stringify(rows.filter(r => !r.done), null, 2)); rows.length'"}}
Compare the source with what is live:
{"name": "substrio_shell", "arguments": {"command": "diff -u /site/index.html site/index.html"}}
Page through a long output:
{"name": "substrio_shell", "arguments": {"command": "cat data/big.csv", "offset": 1048576}}