use crate::compose::{Audience, Concept, Depth, Fragment, Variant, DEFAULT_LOCALE, UNRANKED};
const fn en(
concept: Concept,
key: &'static str,
variant: Variant,
depth: Depth,
audience: Option<Audience>,
body: &'static str,
) -> Fragment {
Fragment {
concept,
key,
variant,
depth,
locale: DEFAULT_LOCALE,
audience,
rank: UNRANKED,
title: None,
body,
}
}
const fn syntax_section(key: &'static str, title: &'static str, body: &'static str) -> Fragment {
Fragment {
concept: Concept::Syntax,
key,
variant: Variant::Example,
depth: Depth::Reference,
locale: DEFAULT_LOCALE,
audience: None,
rank: UNRANKED,
title: Some(title),
body,
}
}
pub const FRAGMENTS: &[Fragment] = &[
en(
Concept::Model,
"what",
Variant::Rule,
Depth::Summary,
None,
"kaish (会sh) is a Bourne-like shell for AI agents: familiar syntax, typed \
values, structured output from every builtin, and pre-execution validation — \
the whole command is checked before it runs, so it never half-runs. Builtins \
run in-process; external commands run via `PATH`.",
),
en(
Concept::Model,
"data-model",
Variant::Rule,
Depth::Summary,
None,
"Values can be structured — a list (`xs=[a b c]`) or a record (`u={k: v}`), not \
only strings. Access is brackets-only (`${r[key]}`, `${xs[0]}`, never dots); \
iterate with `$(values $c)` (elements/values) or `$(keys $c)` (indices/keys); \
`fromjson` / `tojson` bridge real JSON text in and out. See `help syntax` → \
Collections.",
),
en(
Concept::Model,
"welcome",
Variant::Rule,
Depth::Summary,
Some(Audience::Human),
"Type `help` for topics, `help <tool>` for a specific builtin, and `exit` to quit.",
),
en(
Concept::Foundations,
"no-word-splitting",
Variant::Rule,
Depth::Summary,
None,
"**No word splitting.** `$VAR` is always a single value — a variable holding \
spaces stays one argument. Use `split` when you actually want to split on \
whitespace, a delimiter, or a regex.",
)
.ranked(0),
en(
Concept::Foundations,
"no-word-splitting",
Variant::Contrast,
Depth::Reference,
None,
"Bash splits unquoted `$VAR` on `$IFS`; kaish never does, so you never have \
to quote a variable just to keep it whole.",
),
en(
Concept::Foundations,
"quote-to-join",
Variant::Rule,
Depth::Summary,
None,
"**Quote to join.** `$VAR`, `$(cmd)`, and globs are each a separate word \
unless quoted — kaish never pastes adjacent unquoted tokens. To build one \
word from text plus interpolation, wrap the whole thing in double quotes: \
`\"$dir/file.txt\"`, `\"out-$(date +%s).log\"`.",
)
.ranked(1),
en(
Concept::Foundations,
"quote-to-join",
Variant::Contrast,
Depth::Reference,
None,
"Quote the whole word to join text with interpolation — `echo \"$dir/file.txt\"` \
is one path. Bash pastes adjacent tokens into one word; kaish keeps each \
unquoted piece separate and whole, so the unquoted `echo $dir/file.txt` is a \
parse error, marked as such wherever it appears, and the quote is the fix — \
the same discipline `shellcheck` (SC2086) asks for, enforced by the parser.",
),
en(
Concept::Foundations,
"compound-pipeline-stage",
Variant::Rule,
Depth::Summary,
None,
"**A compound statement is a pipeline stage.** `for f in a b; do echo $f; \
done | grep a` works, in any pipeline position. Such a stage *buffers*: \
the whole block runs before the next stage sees a byte, so \
`for … done | head -n 1` runs every iteration.",
)
.ranked(3),
en(
Concept::Foundations,
"structured-output",
Variant::Rule,
Depth::Summary,
None,
"**Structured output.** Every builtin can emit machine-readable data with \
`--json` (`ls --json`, `ps --json`, `kaish-vars --json`).",
)
.ranked(8),
en(
Concept::Foundations,
"structured-output",
Variant::Example,
Depth::Reference,
None,
"```\nls -l --json | jq -r '.[].NAME'\n```",
),
en(
Concept::Foundations,
"newline-split",
Variant::Rule,
Depth::Summary,
None,
"**Newline-split substitution.** `for x in $(cmd)` splits on newlines only — \
one iteration per line; whitespace within a line never splits.",
)
.ranked(5),
en(
Concept::Foundations,
"structured-substitution",
Variant::Rule,
Depth::Summary,
None,
"`$(cmd)` carries structured data: `for i in $(seq 1 5)` iterates five values, \
not split text. Enumerate a collection the same way — `for x in $(values $c)` \
(list elements / record values) or `for k in $(keys $c)` (list indices / record \
keys). A bare `for x in $c` is an error: wrap the collection in `$(...)`.",
)
.ranked(4),
en(
Concept::Foundations,
"glob-strict",
Variant::Rule,
Depth::Summary,
None,
"**Strict globs.** `*.txt` expands to matching files; zero matches is an \
error, not a silent pass-through.",
)
.ranked(6),
en(
Concept::Foundations,
"bracket-test-not-a-command",
Variant::Rule,
Depth::Summary,
None,
"**`[ … ]` is not a command.** No `[` builtin exists — \
`if [ -f file ]; then` fails on the first flag. Use `[[ … ]]` (validated, \
richer tests) or `test`: `if [[ -f file ]]; then`, \
`test -f file && echo yes`.",
)
.ranked(7),
en(
Concept::Foundations,
"pre-validation",
Variant::Rule,
Depth::Summary,
None,
"**Pre-validation.** kaish validates the whole command before running it — \
syntax errors are caught up front, so a command never half-runs.",
)
.ranked(9),
en(
Concept::Foundations,
"crash-not-corrupt",
Variant::Rule,
Depth::Summary,
None,
"**Fail loud, not silent.** kaish prefers to error over corrupting data; \
`set -o trash` snapshots what a delete or overwrite would destroy, so \
the mistake is recoverable.",
)
.ranked(10),
en(
Concept::Foundations,
"boolean-literals-are-lowercase",
Variant::Rule,
Depth::Summary,
None,
"**Only lowercase `true`/`false` are booleans.** `TRUE`, `Yes`, `yes`, `no`, \
`on`, and `off` are ordinary strings — `x=TRUE` binds the string `\"TRUE\"`, \
not a boolean. Write `true`/`false` where a boolean is meant, and check \
with `typeof`.",
)
.ranked(11),
en(
Concept::Foundations,
"json-orchestration",
Variant::Rule,
Depth::Summary,
Some(Audience::Agent),
"When orchestrating tools, prefer `--json` piped through `jq` — consuming \
structured data beats scraping text output.",
)
.ranked(12),
en(
Concept::Foundations,
"collection-literals",
Variant::Rule,
Depth::Summary,
None,
"**Collection literals.** Lists/records have native syntax — `xs=[a b c]`, \
`u={k: v}` — no `fromjson` needed. `push xs v` appends in place (like \
`read`/`unset`, it takes the bareword NAME, not `$xs`); `...$xs` spread \
flattens into a new list — a bare `$xs` nests as one element instead.",
)
.ranked(13),
en(
Concept::Foundations,
"collection-literals",
Variant::Contrast,
Depth::Reference,
None,
"Collections are brackets-only, never dots: `${u.name}` is a loud parse \
error naming the fix (`${u[name]}`) — the `Ident` token allows `.` for \
other uses (filenames), so this can't be caught silently.",
),
en(
Concept::Overlay,
"overlay-mode",
Variant::Rule,
Depth::Summary,
Some(Audience::Agent),
"**Overlay mode** (opt-in: `--overlay` flag or `KernelConfig::with_overlay`). \
All writes go into a virtual in-memory layer; the real filesystem is never \
touched until you explicitly commit. Use `kaish-vfs` to inspect and finalize \
the transaction: `kaish-vfs status` (dirty flag + counts), `kaish-vfs diff` \
(unified diff), `kaish-vfs commit` (write to real files), \
`kaish-vfs reset [path]` (discard edits). \
**Fresh-kernel-per-call rule**: when an embedder runs a fresh kernel per \
`execute()` call (the common pattern), each call gets a fresh overlay \
transaction. `kaish-vfs commit` MUST run in the same call as the writes — if \
you commit in a later call the transaction from the write call was already \
discarded.",
),
syntax_section(
"variables",
"Variables",
r#"```sh
NAME="value" # assignment (no spaces around =)
local NAME="value" # local scope
COUNT=42 # integer
PI=3.14159 # float
ENABLED=true # boolean (only true/false)
```"#,
),
syntax_section(
"expansion",
"Expansion",
r#"```sh
$VAR # simple
${VAR} # braced
${VAR:-default} # default if unset/empty
${#VAR} # string length
$0 $1 $@ $# # script name, args, all args, count
$? # last exit code (0-255)
$$ ${$} # kaish session id — see note below
```
**`$$` is a kaish-internal session identifier**, not the OS PID. It's a
monotonic `u64` counter (starts at 1) assigned at kernel construction;
forks/subshells inherit the parent's value. This is an intentional
divergence from bash — kaish runs embedded inside long-lived host
processes, where the host PID is meaningless to the script."#,
),
syntax_section(
"collections",
"Collections (lists & records)",
r#"```sh
# CONSTRUCTION — native literal syntax, no fromjson needed. Commas optional.
xs=[apple banana cherry] # list — space-separated, like shell words
nums=[1 2 3] # ≡ [1, 2, 3]
u={name: amy, role: maintainer} # record — bareword keys
compact={port:8080} # colon may be spaced or unspaced
r={"content-type": x} # quoted key for anything that isn't a bareword
d={"$k": x} # double-quoted keys interpolate; '$k' stays literal
nested={tags: [a b], meta: {active: true}} # nesting works both ways
# SPREAD (...) flattens; a bare $var nests as ONE element instead:
xs=[1 2]
ys=[0 $xs 4] # [0,[1,2],4] — nests
new=[...$xs date] # [1,2,"date"] — flattens
# Values are also structured JSON — fromjson/tojson bridge real JSON text:
u=$(fromjson '{"name":"amy","tags":["rust","shell"]}')
xs=$(fromjson '[10,20,30]')
# READ ACCESS — brackets only, never dots. Bad access is a loud error.
${u[name]} # record key (bareword = literal key)
${u[$k]} # dynamic key ($var)
${xs[0]} ${xs[-1]} # list index; negative counts from the end
${xs[0:2]} # slice (end-exclusive) → a list
${s[0:5]} ${s[-3:]} # SAME slice on a string → characters, not bytes
${u[tags][0]} # nested path
${#xs} ${#u} # length: list elements / record keys
${u.name} # error — brackets only, use ${u[name]}
${s:0:5} # error — that is bash; use ${s[0:5]}
# ENUMERATE — always wrap the collection in $(keys ...) or $(values ...).
# A bare `for x in $xs` is an ERROR (E012): there is no word splitting.
# keys → indices (list) / keys (record). values → elements (list) / values (record).
for x in $(values $xs); do echo $x; done # each list element: 10 20 30
for i in $(keys $xs); do echo $i; done # each list index: 0 1 2
for k in $(keys $u); do echo $k; done # each record key: name tags
for v in $(values $u); do echo $v; done # each record value
# key + value together — index the record by the loop key:
for k in $(keys $u); do echo "$k = ${u[$k]}"; done
# nested — a subscript access is still a VarRef, so it needs $() too:
for t in $(values ${u[tags]}); do echo $t; done # rust shell
# filter while iterating — test each element, act on the matches:
for x in $(values $xs); do
if [[ $x -gt 15 ]]; then echo "big: $x"; fi # big: 20 big: 30
done
# MEMBERSHIP — RHS must be a collection (see Test Expressions):
if [[ rust in $(values ${u[tags]}) ]]; then echo "has it"; fi # element present?
if [[ name in $u ]]; then echo "has it"; fi # record has key?
if [[ 1 in $(keys $xs) ]]; then echo "in bounds"; fi # index in bounds?
# SHAPE GUARD — an API sometimes returns a list, sometimes a record; check
# before committing to keys/values/for. typeof + [[ -list ]] / [[ -record ]]:
if [[ -record $data ]]; then
for k in $(keys $data); do echo $k; done
elif [[ -list $data ]]; then
for x in $(values $data); do echo $x; done
fi
tojson $u # serialize back to JSON text (--pretty to indent)
# A stream of many documents (JSONL/NDJSON) is fromjsonl/tojsonl, not fromjson/tojson.
# ASSIGNMENT — the same bracket paths write, in place, no spaces around `=`.
# No autovivification — every intermediate must already exist with the right
# shape; the ONLY thing a path-set may create is the final record key.
xs[0]=9 # in-bounds index update (negative index works too)
u[host]=localhost # record key: insert or update
s[web][port]=9000 # deep path
xs[9]=x # error — index out of bounds (push grows lists)
s[api][port]=1 # error — no `api` key (no autoviv; init it first)
user.email=x # error — brackets only, use `user[email]=x`
# push appends to a LIST in place — takes the NAME (like read/unset), a
# top-level name or a bracket path; same lvalue rules as assignment (no
# autoviv on intermediates, leaf must already be a list)
push xs date # xs is now [...,"date"]
push xs $rec # values push as typed Values, not stringified text
push services[web][tags] canary # bracket-path target
```"#,
),
syntax_section(
"paths",
"Paths",
r#"```sh
/usr/bin/foo # absolute
../parent/file # relative with ..
./script.sh # dot-slash (explicit relative)
~/src/project # tilde expands to $HOME
cd # bare cd goes to $HOME
cd - # previous directory
```"#,
),
syntax_section(
"quoting",
"Quoting",
r#"```sh
"hello $NAME" # double quotes — interpolation
"literal \$X" # escape $ to prevent expansion
'hello $NAME' # single quotes — literal, no interpolation
# Quote to JOIN text with interpolation — kaish does not paste adjacent
# unquoted tokens into one word (no implicit concatenation):
"$dir/file.txt" # one path
"out-$(date +%s).log" # one filename (text + command substitution)
echo "/tmp/$(id -u).sock" # one argument
# Unquoted text adjacent to an expansion is a PARSE ERROR (quote the word):
echo $dir/file.txt # error — quote "$dir/file.txt"
echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock"
cmd > $dir/out.txt # error — quote "$dir/out.txt"
# (single-token words like file.txt or v1.2.3 are fine unquoted)
```"#,
),
syntax_section(
"comments",
"Comments",
r#"```sh
# whole-line comment
echo hi # trailing comment — the space before # is required
# `#` starts a comment only at the START of a word. Everywhere else it is an
# ordinary word character, as in bash and sh:
echo abc#3 # prints abc#3
echo 2d25fb02#3 # prints 2d25fb02#3 — an id keeps its #<seq>
echo https://ex.com/p#sec # prints the whole URL, fragment included
echo abc #3 # prints abc — the space makes #3 a comment
# A # that follows a token kaish keeps as its own word is an ERROR, not a
# comment — commenting there would drop the rest of the line at exit 0:
echo "$x#3" # correct — quote the whole word
echo $x#3 # error — # after a variable reference
echo "$(echo a)#3" # correct
echo $(echo a)#3 # error — # after a closing )
# A closing ) ] } is not a comment position either — put a space before #:
case $x in a) # comment # correct
case $x in a)# comment # error — space before #
```"#,
),
syntax_section(
"pipes-redirects",
"Pipes & Redirects",
r#"```sh
cmd1 | cmd2 | cmd3 # pipe stdout
cmd > file # write stdout
cmd >> file # append
cmd < file # stdin from file
cmd 2> file # stderr
cmd &> file # stdout + stderr
cmd 2>&1 # merge stderr into stdout
cat <<EOF # here-doc
content with $VAR
EOF
jq -r '.name' <<< "$R" # here-string — feed expanded word to stdin
cmd > "$dir/out.log" # quote interpolated targets — one word required
cat < "$(find-config)" # command substitution works in a quoted target
```
A redirect target is a single word: quote it when it interpolates
(`> "$dir/f"`, not `> $dir/f`). Bare command substitution as the whole
target (`> $(cmd)`) works; bare text-plus-interpolation does not.
One stdin source per command: `<`, `<<`, and `<<<` cannot be combined.
jq is built-in (native jaq), so `<<<` + jq replaces `echo … | jq`
without a subprocess. jq also accepts real jq's `--arg NAME VALUE`,
`--argjson NAME VALUE`, and `-n` / `--null-input` flags for binding
kaish variables directly into the filter."#,
),
syntax_section(
"operators",
"Operators",
r#"```sh
cmd1 && cmd2 # cmd2 if cmd1 succeeds
cmd1 || cmd2 # cmd2 if cmd1 fails
```"#,
),
syntax_section(
"test-expressions",
"Test Expressions",
r#"```sh
# File: -f (file) -d (dir) -e (exists) -r (readable) -w (writable) -x (executable)
# String: -z (empty) -n (non-empty) == != =~ (regex) !~ (not regex)
# Shape guard: -list -record — the value's shape, not a path stat. A
# defined-but-wrong-shaped value is false; a bare unset $var errors (like
# -z), so a typo isn't silently false. Pairs with the typeof builtin.
# Numeric: -gt -lt -ge -le
# Logic: && || !
# Membership: in (list→element, record→key) / not in — RHS must be a collection
[[ -f config.json && -n $NAME ]]
[[ $N -gt 5 ]]
[[ $s =~ "\.rs$" ]]
if [[ banana in $fruits ]]; then echo "have one"; fi
if [[ tmp not in $services ]]; then echo "not running"; fi
[[ -list $x ]]
[[ -record $x ]]
```"#,
),
syntax_section(
"control-flow",
"Control Flow",
r#"```sh
if [[ -f file ]]; then echo "found"; elif [[ -d dir ]]; then echo "dir"; else echo "none"; fi
for item in "one" "two"; do echo $item; done
for f in *.txt; do cat "$f"; done
for x in $(values $list); do echo $x; done # a collection's elements/values
for k in $(keys $rec); do echo "$k=${rec[$k]}"; done # a record's keys (bare $rec is E012)
while [[ $N -gt 0 ]]; do N=$((N - 1)); done
case $VAR in
hello) echo "matched" ;;
*.rs) echo "Rust file" ;;
*) echo "default" ;;
esac
break; continue; return [N]; exit [N]
# A compound statement is a pipeline stage, in any position. It buffers:
# the whole block runs before the next stage sees a byte.
for f in a b; do echo $f; done | grep a
printf "a\nb\n" | while read l; do echo "got $l"; done
# `:` is the null command — does nothing, exits 0, another spelling of `true`:
if [[ -f log.txt ]]; then :; else touch log.txt; fi # empty branch
: > log.txt # truncate to zero bytes
```"#,
),
syntax_section(
"command-substitution",
"Command Substitution",
r#"```sh
NOW=$(date)
# In for-loops, $(cmd) splits on newlines (only):
for line in $(cat file); do echo $line; done # per-line iteration
for x in $(echo "a b c"); do echo $x; done # one iteration (no \n)
# Whitespace splitting needs explicit split:
for x in $(split "a b c"); do echo $x; done
# Builtins that emit .data (seq, jq, cut, find, glob) iterate per element:
for i in $(seq 1 5); do echo $i; done
# Outside for-loops, $(cmd) is one value:
R=$(printf 'a\nb') # R is "a\nb"
echo "got: $(printf 'x\ny')" # one echo, newline preserved
# A $(...) body takes the full statement grammar: &&/|| chains, ; sequences,
# multi-line bodies, # comments, and control structures (not just a single
# pipeline), quoted or unquoted:
H=$(cd "$repo" && git rev-parse HEAD)
B=$(printf a; printf b) # "ab"
L=$(for f in *.txt; do wc -l < "$f"; done)
# An assignment with no command name takes the exit status of the LAST
# command substitution in its value, or 0 if there was none (bash's rule) —
# so a failed substitution makes the assignment fail, and || and set -e see it:
x="$(read-config)" || x="default" # fallback fires when read-config fails
# kaish-last prints the previous command's .data (or its stdout) as text:
seq 1 5
kaish-last | jq '.[2]' # → 3
seq 1 5
DATA=$(kaish-last) # capture for later use
```"#,
),
syntax_section(
"arithmetic",
"Arithmetic",
r#"```sh
X=$((5 + 3)) # 8
Y=$((X * 2)) # 16
# Operators: + - * / % > < >= <= == !=
# Comparisons return 1/0
```"#,
),
syntax_section(
"functions",
"Functions",
r#"```sh
greet() { echo "Hello, $1!"; }
function greet { echo "Hello, $1!"; }
greet "Amy"
```"#,
),
syntax_section(
"glob-expansion",
"Glob Expansion",
r#"```sh
ls *.txt # expands to matching .txt files
cat src/*.rs # path-prefixed globs work
for f in *.json; do # iterates over matches
jq ".name" "$f"
done
set +o glob # disable bare glob expansion
set -o glob # re-enable (on by default)
```
Zero matches is an error (exit code 1). The `glob` builtin receives its
pattern as written — `glob **/*.rs` needs no quotes, takes multiple patterns,
and adds `--exclude`, `--ftype`, and depth control."#,
),
syntax_section(
"regex",
"Regex (grep, sed, awk)",
r#"```sh
# ERE (egrep-style) everywhere: a|b (…) x+ y? z{2,5} [a-z] ^…$
grep 'error|warn' log.txt # alternation — one call, many terms
sed -E 's/v([0-9]+)\.[0-9]+/\1/' # (…) capture; \1 \2 in the REPLACEMENT
awk '/^(GET|POST) /' access.log # same engine in all three
# GNU BRE spellings are accepted too — \| \(…\) \{n,m\} \+ \? rewrite to ERE:
grep 'error\|warn' log.txt # ≡ error|warn
sed 's/\(a\)\(b\)/\2\1/' # ≡ s/(a)(b)/\2\1/
# A literal | + ? ( ) { }: bracket class, or strict-ERE mode
grep '[|]' f # literal pipe (works in all three)
grep -E 'a\|b' f # -E / -r (grep, sed): backslashed meta = literal
grep -F 'a|b' f # -F: fixed string, nothing is a metachar
grep '\d+\.\w\b' f # \d \w \s \b \. work; \< \> too
sed 's/(a)\1/x/' # ERROR — no backreference IN a pattern
```"#,
),
syntax_section(
"shell-options",
"Shell Options",
r#"```sh
set -e # exit on first error
set -o trash # move rm'd / overwritten files to Trash
set -o glob # enable bare glob expansion (on by default)
set +o trash # disable trash
set +o glob # disable bare glob expansion
```
Env var: `KAISH_TRASH=1` enables trash at startup.
`set -o NAME` / `set +o NAME` on a name kaish doesn't implement exits **1**
and names the valid set (`glob`, `output-limit[=SIZE]`, `trash`) — it never
silently no-ops. `set -o pipefail` fails this way: kaish has no pipefail.
`set -o approvals` and `set -o latch` — retired spellings from the removed
approval subsystem and the confirmation latch — fail the same way; they
turn nothing on. A bare unrecognized short flag (`-u`, `-x`) is still
silently ignored — there's no fixed set to check it against.
**Trash:** `rm` and a truncating overwrite (`cp`, `dd`, `mv`, `patch`,
`sed -i`, `tee`, `write`) snapshot the prior content under `set -o trash`
before they run, so the mistake is recoverable from Trash; `tee -a` append,
new files, and `patch --dry-run` have no prior content to snapshot. Files
<= 10MB and directories always trash; `/tmp`, `/v/*` are excluded. A
**symlink** is the exception: `rm` unlinks the link itself rather than
trashing its target, which is trivially recreatable while the target may
not be. If trash fails, the op errors — no silent fallthrough to a
destructive delete/overwrite. Configure threshold: `kaish-trash config
max-size <bytes>`.
`kaish-trash empty --confirm` is the one operation that always asks — it
discards the recovery net itself. A bare `kaish-trash empty` refuses and
names the flag; the flag carries no token, records nothing, and no session
setting disables the ask."#,
),
syntax_section(
"error-handling",
"Error Handling",
r#"```sh
set -e # exit on first error
cmd || { echo "failed"; exit 1; }
source utils.kai # load script (shared scope)
```"#,
),
syntax_section(
"aliases-background-jobs",
"Aliases & Background Jobs",
r#"```sh
alias ll='ls -la' # define (first word only, not in pipelines)
unalias ll # remove
slow-task & # run in background
jobs # list jobs
wait %1 %2 # wait for specific jobs
```"#,
),
];