550S — The Wandering Earth has 550W. Your terminal gets 550 lines of Shell. Powered by agent.sh. A developer released agent.sh, a single-file AI agent written in pure bash and awk that runs on the shell with only bash, curl and awk as dependencies. The script talks to OpenAI-compatible Chat Completions endpoints, auto-creates an AGENTS.md rules file, a .agents/tools/ directory of executables and a .agents/conv.jsonl conversation history, and includes an inline awk JSON parser for handling tool calls. It is published under the MIT license on GitHub. | | /usr/bin/env bash | | | 550S — Shell. Single-file. Symlink. | | | | | | Built on the shell. | | | Delivered as one file. | | | Extended with symlinks. | | | | | | Source: https://github.com/lloydzhou/agent.sh | | | License: MIT — see LICENSE in the source repository. | | | agent.sh — filesystem-first AI agent in pure bash/awk | | | Chat Completions only; dependencies: bash, curl, awk. | | | Auto-create: AGENTS.md rules , .agents/tools/ executables , .agents/conv.jsonl history . | | | | | | set -uo pipefail | | | | | | MODEL="${MODEL:-gpt-5.6-luna}" | | | API KEY="${OPENAI API KEY:-}" | | | API URL="${OPENAI BASE URL:-https://api.openai.com/v1}/chat/completions" | | | AGENT DIR="${AGENT DIR:-$PWD/.agents}" | | | TOOLS DIR="$AGENT DIR/tools" | | | CONV FILE="$AGENT DIR/conv.jsonl" | | | MAX TURNS="${MAX TURNS:-50}" | | | TOOL TIMEOUT SECS="${TOOL TIMEOUT SECS:-60}" | | | TOOL RESULT MAX BYTES="${TOOL RESULT MAX BYTES:-100000}" | | | USER INPUT="" | | | | | | TOOL DEFS JSON="" | | | DISPLAY LAST CHAR=$'\n' | | | PREV WAS REASONING=false | | | INTERRUPT REQUESTED=false | | | | | | ================= inline awk program ================= | | | Quoted heredoc preserves backslashes; read returns nonzero at EOF. | | | IFS= read -r -d '' AWK PROGRAM <<'AWK PROGRAM' \|\| true | | | Well-formed JSON: direct fields, raw array elements, decoded strings. | | | | | | Read one JSON value, honoring strings and nested containers. | | | function jread at s, i, start, c, depth, quoted, escaped { | | | while substr s, i, 1 ~ / \t\r\n / i++ | | | start = i | | | for ; i <= length s ; i++ { | | | c = substr s, i, 1 | | | if quoted { | | | if escaped escaped = 0 | | | else if c == "\\" escaped = 1 | | | else if c == "\"" { | | | quoted = 0 | | | if depth return substr s, start, i - start + 1 | | | } | | | } else { | | | if depth && c ~ / ,}\ \t\r\n / break | | | if c == "\"" quoted = 1 | | | else if c == "{" \|\| c == " " depth++ | | | else if c == "}" \|\| c == " " { | | | if --depth == 0 return substr s, start, i - start + 1 | | | } | | | } | | | } | | | return substr s, start, i - start | | | } | | | | | | function jfield s, key, i, k, v { | | | sub /^ \t\r\n +/, "", s | | | if substr s, 1, 1 = "{" return "" | | | for i = 2; i < length s ; { | | | while substr s, i, 1 ~ / , \t\r\n / i++ | | | k = jread at s, i ; i += length k | | | while substr s, i, 1 ~ / : \t\r\n / i++ | | | v = jread at s, i | | | if k == "\"" key "\"" return v | | | if v == "" break | | | i += length v | | | } | | | return "" | | | } | | | | | | function jstr s, key, v { | | | v = jfield s, key | | | return substr v, 1, 1 == "\"" ? junesc substr v, 2, length v - 2 : "" | | | } | | | | | | function jsplit s, out, i, v, n { | | | split "", out | | | sub /^ \t\r\n +/, "", s | | | if substr s, 1, 1 = " " return 0 | | | for i = 2; i < length s ; { | | | while substr s, i, 1 ~ / , \t\r\n / i++ | | | v = jread at s, i | | | if v == "" break | | | out ++n = v; i += length v | | | } | | | return n + 0 | | | } | | | | | | ---------- escape / unescape ---------- | | | function jescape s, out, i, c, code { | | | for i = 1; i <= length s ; i++ { | | | c = substr s, i, 1 | | | if code = index "\"\\\b\f\n\r\t", c 0 | | | out = out "\\" substr "\"\\bfnrt", code, 1 | | | else if code = index "\001\002\003\004\005\006\007\010\011\012\013\014\015\016\017\020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037", c 0 | | | out = out sprintf "\\u%04x", code | | | else out = out c | | | } | | | return out | | | } | | | | | | function junesc s, out, i, c, pos, hex, cp, lo { | | | for i = 1; i <= length s ; i++ { | | | c = substr s, i, 1 | | | if c = "\\" { out = out c; continue } | | | if ++i length s break | | | c = substr s, i, 1 | | | if c = "u" { | | | pos = index "bfnrt\"\\/", c | | | out = out pos ? substr "\b\f\n\r\t\"\\/", pos, 1 : c | | | continue | | | } | | | hex = substr s, i + 1, 4 | | | cp = hex to int hex | | | if length hex < 4 \|\| cp < 0 { out = out "\\u"; continue } | | | if cp = 55296 && cp <= 56319 && i + 10 <= length s && substr s, i + 5, 2 == "\\u" { | | | lo = hex to int substr s, i + 7, 4 | | | if lo = 56320 && lo <= 57343 { | | | out = out utf8 65536 + cp - 55296 1024 + lo - 56320 | | | i += 10; continue | | | } | | | } | | | out = out cp = 55296 && cp <= 57343 ? "\\u" hex : utf8 cp | | | i += 4 | | | } | | | return out | | | } | | | | | | function hex to int hex, i, d, value { | | | for i = 1; i <= length hex ; i++ { | | | d = index "0123456789abcdef", tolower substr hex, i, 1 - 1 | | | if d < 0 return -1 | | | value = value 16 + d | | | } | | | return value + 0 | | | } | | | | | | function utf8 cp { | | | Decoding runs under LC ALL=C; build UTF-8 bytes explicitly. | | | if cp <= 127 return sprintf "%c", cp | | | if cp <= 2047 return sprintf "%c%c", 192 + int cp / 64 , 128 + cp % 64 | | | if cp <= 65535 return sprintf "%c%c%c", 224 + int cp / 4096 , 128 + int cp % 4096 / 64 , 128 + cp % 64 | | | return sprintf "%c%c%c%c", 240 + int cp / 262144 , 128 + int cp % 262144 / 4096 , 128 + int cp % 4096 / 64 , 128 + cp % 64 | | | } | | | | | | ---------- CLI ---------- | | | BEGIN { | | | if json mode == "escape string" { | | | if getline json input < 0 json input = "" | | | else while getline line 0 json input = json input "\n" line | | | printf "%s", jescape substr json input, 1, length json input - 1 | | | exit 0 | | | } | | | } | | | SSE - TSV; tool calls: name, id, call JSON, argv prefixed with '=' to preserve empty args. | | | | | | function esc s, out, i, c { | | | for i = 1; i <= length s ; i++ { | | | c = substr s, i, 1 | | | out = out c == "\\" ? "\\\\" : c == "\t" ? "\\t" : c == "\n" ? "\\n" : c == "\r" ? "\\r" : c | | | } | | | return out | | | } | | | | | | BEGIN { reset } | | | | | | /^error\t/ { print "error\t" esc substr $0, 7 ; fflush ; done = 1; exit 0 } | | | /^retry$/ { reset ; print "retry"; fflush ; next } | | | /^data: $$ DONE $$ / { | | | emit tools | | | if pending stop = "" print "finish reason\t" pending stop | | | fflush ; done = 1; next | | | } | | | /^data: / { | | | jsplit jfield substr $0, 7 , "choices" , choices | | | choice = choices 1 | | | delta = jfield choice, "delta" | | | fr = jstr choice, "finish reason" | | | if fr = "" pending stop = fr | | | content = jstr delta, "content" | | | if content = "" print "content\t" esc content | | | reasoning = jstr delta, "reasoning content" | | | if reasoning = "" print "reasoning\t" esc reasoning | | | parse tool calls jfield delta, "tool calls" | | | if fr == "tool calls" emit tools | | | fflush | | | } | | | END { | | | if json mode = "escape string" && done && pending stop == "" print "error\tStream interrupted no DONE received " | | | } | | | | | | function reset { | | | done = 0; pending stop = ""; tc max = -1 | | | split "", tc name ; split "", tc id ; split "", tc args | | | } | | | | | | function parse tool calls json, n, p, tc, idx, f, id { | | | n = jsplit json, chunks | | | for p = 1; p <= n; p++ { | | | tc = chunks p | | | idx = jfield tc, "index" | | | if idx ~ /^ 0-9 +$/ continue | | | idx += 0 | | | if idx tc max tc max = idx | | | f = jfield tc, "function" | | | id = jstr tc, "id" | | | if id = "" tc id idx = id | | | tc name idx = tc name idx jstr f, "name" | | | tc args idx = tc args idx jstr f, "arguments" | | | } | | | } | | | | | | function emit tools idx, n, i, call, line { | | | for idx = 0; idx <= tc max; idx++ { | | | if tc args idx == "" continue | | | Preserve the arguments JSON text; decode only the execution copy. | | | call = "{\"id\":\"" jescape tc id idx "\",\"type\":\"function\",\"function\":{\"name\":\"" jescape tc name idx "\",\"arguments\":\"" jescape tc args idx "\"}}" | | | line = "tool calls\t" esc tc name idx "\t" esc tc id idx "\t" esc call | | | n = jsplit jfield tc args idx , "args" , argv | | | for i = 1; i <= n; i++ line = line "\t=" esc junesc substr argv i , 2, length argv i - 2 | | | print line | | | tc args idx = "" | | | } | | | fflush | | | } | | | AWK PROGRAM | | | | | | ================= utils ================= | | | util awk run { LC ALL=C LANG=C awk "$@"; } | | | | | | util unescape { | | | Decode line-protocol fields into REPLY without stripping trailing newlines. | | | local s="$1" c | | | REPLY="" | | | while "$s" == \\ ; do | | | REPLY+="${s%%\\ }"; s="${s \\}" | | | c="${s:0:1}"; s="${s:1}" | | | case "$c" in n REPLY+=$'\n' ;; t REPLY+=$'\t' ;; r REPLY+=$'\r' ;; REPLY+="$c" ;; esac | | | done | | | REPLY+="$s" | | | } | | | | | | util run timeout { | | | local seconds="$1" pid watcher rc; shift | | | "$@" <&0 2 &1 & pid=$ | | | sleep "$seconds"; kill -TERM "$pid" 2 /dev/null /dev/null 2 &1 & watcher=$ | | | wait "$pid" 2 /dev/null; rc=$? | | | kill "$watcher" 2 /dev/null | | | return $ rc == 143 ? 124 : rc | | | } | | | | | | util die { | | | printf '\033 31mError: %s\033 0m\n' "$ " &2 | | | exit 1 | | | } | | | | | | util json escape { | | | A final sentinel preserves trailing newlines through awk's record reader. | | | printf '%s.' "${1:-}" \| util awk run -v json mode=escape string "$AWK PROGRAM" | | | } | | | | | | ================= system prompt ================= | | | agent build prompt { | | | cat <<'PROMPT' | | | You are agent.sh, a minimal filesystem-first agent running in a terminal. | | | | | | Tools: | | | - Tools are plain executables binaries, symlinks, scripts in the agent tools directory; the tool name is the file name. | | | - Each call passes "args": an array of strings. Elements are handed to the command as separate arguments — like Docker CMD exec form. No shell expansion: quotes, spaces, and $ are literal. Pass when the tool takes no arguments. | | | - Read each tool's description before calling it. | | | - A tool returns stdout and stderr. Non-zero exit is prefixed "Error exit N " — read the message and self-correct on the next call. Long output is truncated; use head/tail/grep to narrow large reads. | | | - Shell composition pipes, $ ... , globs does not apply to args. If a "bash" tool exists, call it with "-c", "