substrio_shell runs one shell command over your app, the way you work on a checkout: grep -rn, sed -n 10,40p, find ... | xargs sed -i, a heredoc, a pipe. It is the operation source.shell (POST /v1/_source/shell, MCP tool substrio_shell).
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 ends with status 0, every
/srcchange lands as ONE commit on the branch (its message:message, else whatcommit -mset, else the command itself), then each/fileswrite is stored. - Any other status lands nothing. A refusal ends the call at once and lands 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 there only when the call committed; files only when it stored files. exit_code is the last pipeline's status, as in a shell (grep's 1 for no match is an answer, not a refusal).
Output past the output budget is never dropped in silence: the answer carries the first part, truncated: true, and next_offset; call again with offset: next_offset for the next part. stderr_truncated: true says the errors were 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: every caller that may read the app's source may run it, a read-only grant included. A write is checked at the write against the operation it stands for: a /src write as source.edit, a /files write as file.upload. A caller whose grant does not reach it gets SHELL_WRITE_NOT_GRANTED at the first write, and nothing lands.
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}}