| | #!/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", "<command line>"] for full shell features. | | | - You may issue several independent tool calls in one turn. | | | PROMPT |
| | local locale="${LC_ALL:-${LC_MESSAGES:-${LANG:-en_US}}}" lang_name skill name desc indexed="" |
| | locale="${locale%%.*}" |
| | case "$locale" in ""\|C\|C.*\|POSIX) locale="en_US" ;; esac |
| | case "$locale" in zh*) lang_name="Chinese" ;; *) lang_name="English" ;; esac |
| | printf 'Environment:\nlang: %s\npwd: %s\nhome: %s\nplatform: %s\nshell: %s\n' \ |
| | "$locale" "$PWD" "${HOME:-?}" "$(uname -s 2>/dev/null \|\| echo unknown)" "${SHELL:-unknown}" |
| | printf '\nBy default, use %s (%s) for output, including reasoning/thinking, unless user instructions specify otherwise. Code, commands, and file content remain as-is.\n' "$lang_name" "$locale" | | | for skill in "$AGENT_DIR"/skills/*/SKILL.md; do |
| | [[ -f "$skill" ]] \|\| continue |
| | if [[ -z "$indexed" ]]; then |
| | printf '\n<skill-index>\nWhen a skill matches the task, read its SKILL.md using any suitable available tool (e.g. cat or bash); no dedicated skill tool is needed. If none is available, do not assume its contents. Resolve relative paths from the skill directory.\n' | | | indexed=1 | | | fi |
| | name="${skill%/SKILL.md}"; name="${name##*/}" |
| | desc=$(awk ' |
| | { sub(/\r$/, "") } |
| | NR == 1 { if ($0 != "---") exit; next } |
| | /^---$/ { exit } |
| | /^description:[ \t]*/ { sub(/^description:[ \t]*/, ""); sub(/[ \t]+$/, ""); if ($0 ~ /^[\|>][-+0-9]*$/) exit; if ($0 ~ /^".*"$/ \|\| $0 ~ /^\047.*\047$/) $0 = substr($0, 2, length($0) - 2); print; exit } |
| | ' "$skill") | | | printf -- '- %s%s\n path: %s\n' "$name" "${desc:+: $desc}" "$skill" | | | done |
| | [[ -n "$indexed" ]] && printf '</skill-index>\n' |
| | if [[ -f "$PWD/AGENTS.md" ]]; then |
| | printf '\nUser instructions:\n' | | | cat "$PWD/AGENTS.md" | | | fi | | | } | | | |
| | # ================= tools (filesystem-discovered command templates) ================= |
| | tools_load() { |
| | TOOL_DEFS_JSON="" | | | [[ -d "$TOOLS_DIR" ]] || return 0 | | | local f name desc | | | for f in "$TOOLS_DIR"/*; do |
| | [[ -x "$f" ]] \|\| continue # tools are executables: binaries, symlinks, scripts |
| | name="${f##*/}" |
| | [[ "$name" =~ ^[A-Za-z0-9_-]{1,64}$ ]] \|\| { printf 'skip tool (bad name): %s\n' "$name" >&2; continue; } |
| | desc=$(tools_auto_desc "$f") |
| | [[ -n "$desc" ]] \|\| desc="Run $name" |
| | TOOL_DEFS_JSON+="${TOOL_DEFS_JSON:+,}"'{"type":"function","function":{"name":"'"$name"'","description":"'"$(util_json_escape "$desc")"'","parameters":{"type":"object","properties":{"args":{"type":"array","items":{"type":"string"},"description":"command-line arguments for '"$name"', passed as separate arguments (exec form); [] if none"}}}}}' | | | done | | | } | | | |
| | tools_auto_desc() { |
| | # Exact-name man match; fall back to --help with stdin=/dev/null and a timeout. |
| | local name="${1##*/}" d |
| | d=$(man -f "$name" 2>/dev/null \| awk -v name="$name" '$0 ~ "^" name "\$[0-9]+\$" { sub(/^[^(]*$[^)]*$[[:space:]]*-[[:space:]]*/, ""); print; exit }') |
| | [[ -z "$d" ]] && d=$(util_run_timeout 3 "$1" --help </dev/null 2>&1 \| grep -m1 -v '^[[:space:]]*$') |
| | printf '%s' "$d" | | | } | | | |
| | tool_execute() { |
| | local name="$1" f="$TOOLS_DIR/$1" output rc; shift |
| | [[ -x "$f" ]] \|\| { printf 'Error: no such tool: %s' "$name"; return 0; } |
| | output=$(util_run_timeout "$TOOL_TIMEOUT_SECS" bash -c 'exec "$0" "$@"' "$f" "$@" 2>&1) |
| | rc=$? |
| | (( rc != 0 )) && output="Error (exit $rc): $output" |
| | if (( ${#output} > TOOL_RESULT_MAX_BYTES )); then |
| | local head=$(( (TOOL_RESULT_MAX_BYTES + 1) / 2 )) tail=$(( TOOL_RESULT_MAX_BYTES / 2 )) |
| | output="${output:0:head}"$'\n...[truncated]...\n'"${output: ${#output}-tail}" |
| | fi | | | printf '%s' "$output" | | | } | | | |
| | # ================= conversation (conv.jsonl: one OpenAI message per line) ================= |
| | conv_append() { printf '%s\n' "$1" >> "$CONV_FILE"; } |
| | |
| | # ================= llm ================= |
| | llm_call() { |
| | local body='{"model":"'"$MODEL"'","messages":[{"role":"system","content":"'"$(util_json_escape "$(agent_build_prompt)")"'"}' |
| | body+="$(awk '{printf ",%s", $0}' "$CONV_FILE")"']' |
| | [[ -n "$TOOL_DEFS_JSON" ]] && body+=',"tools":['"$TOOL_DEFS_JSON"']' |
| | body+=',"stream":true}' |
| | curl -sS --no-buffer -D - --retry 2 --retry-delay 1 --retry-max-time 20 \ |
| | --connect-timeout 5 --speed-limit 1 --speed-time 60 \ |
| | -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ |
| | -d "$body" "$API_URL" 2>&1 \ |
| | | util_awk_run ' |
| | { sub(/\r$/, "") } |
| | /^curl: / { print "error\t" $0; fflush(); exit 1 } |
| | /^HTTP// { |
| | if (body) { print "retry"; fflush() } |
| | code = $2; body = 0; err = ""; next |
| | } |
| | /^[ \t]*$/ && !body { body = 1; next } |
| | !body { next } |
| | code >= 400 { err = err (err == "" ? "" : "\n") $0; next } |
| | { print; fflush() } |
| | END { |
| | if (code >= 400) { |
| | print "error\tHTTP " code ": " (err == "" ? "(empty)" : err) |
| | fflush() |
| | } | | | } | | | ' \ | | | | util_awk_run "$AWK_PROGRAM" | | | } | | | |
| | # ================= display ================= |
| | display_ensure_newline() { |
| | if [[ "$DISPLAY_LAST_CHAR" != $'\n' ]]; then printf '\n'; DISPLAY_LAST_CHAR=$'\n'; fi |
| | return 0 | | | } | | | |
| | display() { |
| | local kind="$1"; shift |
| | case "$kind" in | | | content|reasoning) |
| | [[ -n "$1" ]] \|\| return 0 |
| | if [[ "$kind" == reasoning ]]; then |
| | PREV_WAS_REASONING=true | | | printf '\033[90m%s\033[0m' "$1" | | | else | | | [[ "$PREV_WAS_REASONING" == true ]] && printf '\n' | | | PREV_WAS_REASONING=false | | | printf '%s' "$1" | | | fi |
| | DISPLAY_LAST_CHAR="${1: -1}"; return 0 ;; |
| | tool) display_ensure_newline; printf '\033[33m[tool] %s %s\033[0m\n' "$1" "${2:0:120}" ;; |
| | result) |
| | local first="${2%%$'\n'*}" |
| | printf '\033[90m[%s done] %s\033[0m\n' "$1" "${first:0:120}" ;; |
| | error) display_ensure_newline; printf '\033[31mError: %s\033[0m\n' "$1" >&2 ;; |
| | retry) printf '\033[90mretrying...\033[0m\n' >&2 ;; |
| | esac | | | DISPLAY_LAST_CHAR=$'\n' | | | } | | | |
| | # ================= agent loop ================= |
| | agent_loop() { |
| | local turn=0 stop="" text="" reasoning="" reasoning_field="" calls="" tool_messages="" _line _type _rest |
| | conv_append '{"role":"user","content":"'"$(util_json_escape "$1")"'"}' |
| | INTERRUPT_REQUESTED=false | | | trap 'INTERRUPT_REQUESTED=true' INT |
| | while (( turn < MAX_TURNS )); do |
| | (( turn++ )) \|\| true |
| | stop=""; text=""; reasoning=""; calls=""; tool_messages="" |
| | exec 8< <(llm_call) |
| | while IFS= read -r _line <&8; do |
| | [[ "$INTERRUPT_REQUESTED" == true ]] && { stop="interrupted"; break; } |
| | _type="${_line%%$'\t'*}" |
| | _rest="${_line#*$'\t'}" |
| | case "$_type" in | | | content) | | | util_unescape "$_rest" | | | display content "$REPLY" | | | text+="$REPLY" ;; | | | reasoning) | | | util_unescape "$_rest"; display reasoning "$REPLY"; reasoning+="$REPLY" ;; | | | tool_calls) | | | # name, id, complete call JSON, then '='-prefixed argv fields. |
| | local _f=() _argv=() _name _id _call _output _i |
| | IFS=$'\t' read -r -a _f <<<"$_rest" |
| | util_unescape "${_f[0]}"; _name="$REPLY" |
| | util_unescape "${_f[1]}"; _id="$REPLY" |
| | util_unescape "${_f[2]}"; _call="$REPLY" |
| | for _i in ${_f[@]+"${_f[@]:3}"}; do |
| | util_unescape "${_i:1}"; _argv+=("$REPLY") |
| | done |
| | display tool "$_name" "${_argv[*]-}" |
| | _output=$(tool_execute "$_name" ${_argv[@]+"${_argv[@]}"}) |
| | display result "$_name" "$_output" |
| | calls+="${calls:+,}$_call" |
| | tool_messages+='{"role":"tool","tool_call_id":"'"$(util_json_escape "$_id")"'","content":"'"$(util_json_escape "$_output")"'"}'$'\n' ;; |
| | retry) | | | display retry | | | text=""; reasoning=""; calls=""; tool_messages="" ;; | | | finish_reason) | | | stop="$_rest" ;; | | | error) | | | util_unescape "$_rest"; display error "$REPLY" | | | stop="error"; break ;; | | | esac | | | done | | | exec 8<&- | | | [[ "$stop" == "interrupted" || "$stop" == "error" ]] && break | | | # Persist this assistant turn, then continue only when tools were called | | | reasoning_field="" |
| | [[ -n "$reasoning" ]] && reasoning_field=',"reasoning_content":"'"$(util_json_escape "$reasoning")"'"' |
| | if [[ -n "$calls" ]]; then |
| | local content_field="null" |
| | [[ -n "$text" ]] && content_field='"'"$(util_json_escape "$text")"'"' |
| | conv_append '{"role":"assistant","content":'"$content_field"',"tool_calls":['"$calls"']'"$reasoning_field"'}' |
| | printf '%s' "$tool_messages" >> "$CONV_FILE" | | | [[ "$stop" == "tool_calls" ]] && continue | | | else | | | [[ -n "$text$reasoning" ]] && conv_append '{"role":"assistant","content":"'"$(util_json_escape "$text")"'"'"$reasoning_field"'}' | | | fi | | | break | | | done | | | trap - INT | | | if (( turn >= MAX_TURNS )); then | | | display error "Max turns ($MAX_TURNS) reached" | | | return 1 | | | fi | | | [[ "$stop" == error ]] && return 1 | | | return 0 | | | } | | | |
| | # ================= interactive ================= |
| | interactive_mode() { |
| | local line | | | printf '\033[36magent.sh (%s) — type exit or Ctrl+D to quit\033[0m\n' "$MODEL" | | | while true; do |
| | if ! IFS= read -e -r -p $'\033[32m550S>\033[0m ' line; then printf '\n'; break; fi |
| | [[ "$line" == "exit" \|\| "$line" == "quit" ]] && break |
| | [[ -z "$line" ]] && continue |
| | history -s "$line" 2>/dev/null || true | | | agent_loop "$line" | | | display_ensure_newline | | | done | | | printf '\033[36mGoodbye!\033[0m\n' | | | } | | | |
| | # ================= cli ================= |
| | usage() { |
| | cat <<'EOF' | | | agent.sh — filesystem-first AI agent (OpenAI Chat Completions) | | | | | | Usage: | | | agent.sh [prompt] chat; no prompt + tty opens interactive REPL | | | agent.sh < file read prompt from stdin | | | fresh conversation: mv -i .agents/conv.jsonl ".agents/conv-$(date +%Y%m%d-%H%M%S).jsonl" | | | | | | Missing AGENTS.md, tools directory, and history file are created on startup. | | | | | | Options: |
| | -m, --model NAME model override (env MODEL) |
| | -h, --help this help |
| | | | | Environment: | | | OPENAI_API_KEY required | | | OPENAI_BASE_URL default https://api.openai.com/v1 | | | MODEL default gpt-5.6-luna | | | EOF | | | } | | | |
| | parse_args() { |
| | while (( $# > 0 )); do |
| | case "$1" in |
| | -m\|--model) [[ -n "${2:-}" ]] \|\| util_die "$1 requires a model"; MODEL="$2"; shift 2 ;; |
| | -h\|--help) usage; exit 0 ;; |
| | -*) util_die "unknown option: $1 (see --help)" ;; |
| | *) [[ -n "$USER_INPUT" ]] && util_die "unexpected extra argument: $1" |
| | USER_INPUT="$1"; shift ;; |
| | esac | | | done | | | return 0 | | | } | | | | | | main() { | | | parse_args "$@" | | | [[ -n "$API_KEY" ]] || util_die "OPENAI_API_KEY is not set" | | | mkdir -p "$TOOLS_DIR" && touch "$CONV_FILE" || util_die "Cannot initialize $AGENT_DIR" | | | if [[ ! -e "$PWD/AGENTS.md" ]]; then | | | printf '# Agent instructions\n\n' > "$PWD/AGENTS.md" || util_die "Cannot create AGENTS.md" | | | fi | | | tools_load |
| | if [[ -z "$USER_INPUT" ]]; then |
| | if [[ -t 0 ]]; then interactive_mode; return $?; fi |
| | USER_INPUT=$(cat) |
| | fi | | | agent_loop "$USER_INPUT" | | | local rc=$? | | | display_ensure_newline | | | return "$rc" | | | } | | | | | | main "$@" |