commit 494bee8b77a3902fea4c45e2d5b1b6db28fe004e Author: Luxferre Date: Sat Oct 3 09:15:02 2026 +0300 Solo-C: a tiny coding agent in a single C file main.c is the whole agent: a readline REPL that talks to any OpenAI-compatible /chat/completions endpoint and exposes exactly one tool, bash(command). Commands run through the user's shell with stdout and stderr merged, a hard timeout, and a process-group kill; the output and an exit marker go back to the model until it answers without calling a tool. make static-deps builds curl and c-ares from source into static-deps/, so `make static` can link a fully static binary on distros that get in the way: the distro libcurl.a drags in libpsl, brotli, zstd and nghttp2, whose static objects are GCC 14 LTO bytecode a newer linker cannot read, and a minimal system often ships no libcares.a at all. Two details worth keeping in mind when editing mkstaticdeps.sh. curl 8.22 takes --enable-ares=PREFIX rather than the older --with-cares, and it only warns about options it does not recognise, so the wrong spelling turns off async DNS in silence instead of failing. And link order is load-bearing: libcares.a has to come after libcurl.a or ares_* stays undefined. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de1230a --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Build artifacts. +# Both names are produced by the Makefile targets `solo` and `static`. +# Anchor on the root so a directory called solo is still tracked. +/solo +/solo-static +*.o +*.a +*.so +*.so.* + +# Prebuilt libcurl.a for `make static`, regenerated by `make static-deps` +# (see mkstaticdeps.sh). Large, and tied to the machine that built it. +/static-deps/ +/curl-*/ + +# Per-directory session state: the live model parameters, the tool schema and +# the full conversation transcript, which can contain secrets. Runtime data, so +# it must never be committed. +.solo_sess.json +*.solo_sess.json + +# Readline history, in case HOME or XDG is pointed here in some setups. +.solo_history +.history + +# Scratch space the agent is told to use. +/solo-tmp/ +/tmp/ + +# Editor and tooling droppings. +*~ +*.bak +*.orig +*.rej +*.swp +*.swo +.*.sw? +.\#* +\#*\# + +# Vim. +.vim/ +*.vim +*.swo + +# Emacs. +*.elc +*.symc +.\#* +\#*\# + +# Compiled binaries and debug output from ad-hoc experiments. +/core +/core.* +vgcore.* +a.out +gdb.core + +# Coverage and profiling, if instrumentation is ever added. +*.gcda +*.gcno +*.gcov +*.profraw +*.profdata + +# OS cruft. +.DS_Store +Thumbs.db +desktop.ini + +# Secrets and local overrides. Commit a .env.example instead. +.env +.env.local +SOLO_API_KEY +SOLO_SYSPROMPT.local diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..638597a --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +CC = gcc +CFLAGS = -Wall -Wextra -O2 -std=c99 -march=native $(shell pkg-config --cflags libcurl json-c readline) +LDLIBS = $(shell pkg-config --libs libcurl json-c readline) + +# Fully static binary. +# +# The distro libcurl.a cannot be linked statically on Alpine: it pulls in +# libpsl, libbrotli, libzstd and libnghttp2, whose static archives hold LTO +# bytecode from GCC 14 that the installed GCC 15 cannot read ("bytecode +# stream ... generated with LTO version 14.0 instead of the expected 15.1"), +# so psl_*/Brotli*/ZSTD* never resolve. -fno-lto does not help; the objects +# are bytecode, not C. +# +# static-deps/libcurl.a is curl built without those four libraries, leaving +# only LTO-free static deps. Regenerate with: make static-deps +# +# c-ares is built from source by the same script, for two reasons: a minimal +# system often has no libcares.a at all (Void's c-ares-devel ships the shared +# library only), and where an archive does exist it can carry the same LTO +# bytecode as the distro libcurl.a. Building it also pins the version. +# +# Archive order matters: libcurl.a pulls in ares_* symbols, so libcares.a has to +# come after it on the link line. +CURL_STATIC ?= static-deps/libcurl.a +CARES_STATIC ?= static-deps/libcares.a +STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistring \ + -lreadline -lncursesw -ljson-c -lm -ldl -pthread + +solo: main.c + $(CC) $(CFLAGS) -o solo main.c $(LDLIBS) + strip solo + +static: main.c + @for f in $(CURL_STATIC) $(CARES_STATIC); do \ + test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \ + done + $(CC) $(CFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS) + strip solo-static + +static-deps: + sh ./mkstaticdeps.sh + +# Rebuild both archives from scratch, ignoring what is already there. +static-deps-force: + FORCE=1 sh ./mkstaticdeps.sh + +PREFIX ?= /usr/local +BINDIR = $(PREFIX)/bin +INSTALL ?= install + +install: solo + $(INSTALL) -d $(DESTDIR)$(BINDIR) + $(INSTALL) -m 755 solo $(DESTDIR)$(BINDIR)/solo + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/solo $(DESTDIR)$(BINDIR)/solo-static + +clean: + rm -f solo solo-static + +.PHONY: clean static static-deps static-deps-force install uninstall diff --git a/README.md b/README.md new file mode 100644 index 0000000..17f727f --- /dev/null +++ b/README.md @@ -0,0 +1,432 @@ +# Solo-C + +A tiny coding agent in a single C file. + +Solo-C is a port of the [Solo](https://solo.luxferre.top) coding agent: a +read-eval-print loop that talks to any OpenAI-compatible chat-completions +endpoint and gives the model exactly one tool, `bash`. The model writes a shell +command, Solo-C runs it through your shell, feeds the combined output and exit +code back, and repeats until the model answers without asking for a tool. + +The whole client is one ~400-line file, `main.c`, with no third-party code +beyond libcurl, json-c and readline. + +## Requirements + +Build-time: + +- A C99 compiler (GCC or Clang; built and tested with GCC 14) +- GNU make +- `pkg-config` +- libcurl, json-c and GNU readline development headers + +Runtime (dynamic build): the matching shared libraries, plus a shell. + +Install the dependencies with your package manager, for example: +``` +# Debian / Ubuntu +sudo apt install build-essential pkg-config libcurl4-openssl-dev libjson-c-dev libreadline-dev + +# Alpine +sudo apk add build-base pkgconf curl-dev json-c-dev readline-dev + +# Void Linux +sudo xbps-install -S gcc make pkg-config curl-devel json-c-devel readline-devel +``` +`make` picks up the include and library flags through `pkg-config`, so there is +nothing to configure by hand. + +## Build +``` +make +``` +That produces a stripped dynamic binary named `solo` in the project directory. +The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`. + +Other targets: + + make clean # remove solo and solo-static + make install # install to $(PREFIX)/bin, default /usr/local/bin + make uninstall # remove the installed binaries + make static-deps # build the static curl and c-ares archives, see below + +## Static build + +`make static` links a fully static `solo-static`, useful for running the agent +on a machine with no shared libraries, or from a rescue shell. + +It does not work out of the box everywhere, for two reasons. + +On Alpine and on any distro whose `libcurl.a` was built by a different GCC than +yours, the distro archive pulls in libpsl, brotli, zstd and nghttp2, whose +static objects ship LTO bytecode from GCC 14. A GCC 15 linker refuses to read it +("bytecode stream ... generated with LTO version 14.0 instead of the expected +15.1"), so `psl_*`, `BrotliDecoder*` and `ZSTD_*` never resolve. `-fno-lto` does +not help, because the objects are bytecode, not C. + +And a minimal system often has no `libcares.a` at all. Void Linux is a live +example: c-ares ships there as a shared library only, so `-lcares` in the link +line has nothing to resolve against and `make static` fails outright. + +The fix is to build both archives from source. Build them once with: + + make static-deps + +That fetches and compiles c-ares and curl into `static-deps/`: + + static-deps/libcares.a c-ares, built static, from source + static-deps/libcurl.a curl, built static, without psl/brotli/zstd/nghttp2 + static-deps/cares-prefix/ headers and libcares.pc that curl's configure reads + +It needs network access, a compiler, and a few minutes. Then: + + make static + +c-ares is built here rather than taken from the system for the reason above: no +archive may exist, and where one does it can carry the same LTO bytecode as the +distro libcurl.a. Building it also pins the version, so two machines do not +quietly disagree about which resolver is inside the binary. + +The two archives go on the link line in that order, because `libcurl.a` +references `ares_*` symbols and the linker only pulls archive members that +satisfy an undefined symbol seen earlier. Reverse them and you get undefined +references to `ares_library_init`, `ares_version` and friends. + +The result has no HTTP/2, no brotli or zstd `Content-Encoding`, and no +public-suffix cookie checks. None of that matters to a JSON API client. + +Versions and reuse: + +| Variable | Default | What it pins | +| --------------- | ------- | ---------------------------------------- | +| `CURL_VERSION` | 8.22.0 | curl release fetched from curl.se | +| `CARES_VERSION` | 1.34.8 | c-ares release, fetched from c-ares.dev | +| `FORCE` | unset | set to `1` to rebuild even if fresh | + +`make static-deps` reuses whatever is already built, so a rerun is instant. +Reuse is keyed on a stamp file beside each archive rather than on the file +merely existing, so bumping a version rebuilds exactly that one archive, and a +stale `libcurl.a` from an unrelated build can never be mistaken for a current +one. To rebuild both regardless: + + make static-deps-force + +One trap worth knowing: curl 8.22 spells the option `--enable-ares[=PREFIX]`, +not the older `--with-cares`, and curl only *warns* about options it does not +recognise. A mistyped flag therefore fails silently, disabling async DNS and +leaving you to find out at link time from a pile of undefined `ares_*` +symbols. The same applies to `--without-librtmp`, which curl 8.22 no longer +knows. Neither spelling appears in the script. + +Override `CURL_STATIC` or `CARES_STATIC` if you keep prebuilt archives +elsewhere. Note that `make static` still wants static versions of ssl, crypto, +z, idn2, unistring, readline, ncursesw and json-c from your distro; only the +two problem archives are built from source here. + +## Install + + sudo make install # /usr/local/bin/solo + sudo make install PREFIX=$HOME/.local # ~/.local/bin/solo + make install DESTDIR=/tmp/pkg PREFIX=/usr + +Remember to put `~/.local/bin` on your `PATH` if you install there. + +## Run + + ./solo + +or, once installed: + + solo + +Start in the directory you want the agent to work in. Solo-C stores its session +next to you in `.solo_sess.json`, so different projects keep different +conversations, and the agent's shell commands run with that directory as their +working directory. + +Set the endpoint and key before the first request: + + export SOLO_API_KEY=your-key-here + solo + +or from inside the session: + + > /api_key your-key-here + > /endpoint https://api.kilo.ai/api/openrouter + +Then just type what you want: + + > list every *.c file here with its line count, then count the total + +Anything that does not start with `/` or `!` is sent to the model as a task. +A trailing backslash continues a line, which is handy for long tasks: + + > build the project, then run the tests, and fix whatever fails \ + +## Configuration + +Everything is configurable at three levels, from weakest to strongest: + +1. Built-in defaults (see `SOLO_*` below). +2. Environment variables, read once at startup. +3. Session commands, which change the live session and are saved to disk. + +| Setting | Default | How to change it | +| ------------- | ------------------------------------ | ----------------------------------- | +| endpoint | `https://api.kilo.ai/api/openrouter` | `SOLO_ENDPOINT`, `/endpoint` | +| api key | unset | `SOLO_API_KEY`, `/api_key` | +| model | `kilo-auto/free` | `SOLO_MODEL`, `/model` | +| reasoning | `high` | `SOLO_REASON`, `/reasoning_effort` | +| temperature | `0.7` | `SOLO_TEMP`, `/temperature` | +| timeout | `120` seconds | `SOLO_TIMEOUT` (env only) | +| system prompt | built-in | `SOLO_SYSPROMPT` | +| scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` | +| colors | on when stdout is a TTY | `NO_COLOR=1` | + +Session commands take precedence and are persisted, so a model you set by hand +survives a restart even if the environment says something else. + +Two settings cannot be changed from inside the session, because they are read +once at startup: the timeout (`SOLO_TIMEOUT`) and the system prompt +(`SOLO_SYSPROMPT`). `/timeout 600` is accepted by the generic parameter handler +below, but it only writes a `timeout` field into the saved session, which the +API ignores and which does not change the command timeout. Restart with the +environment set instead. + +## Slash commands + +| Command | Effect | +| ------------------ | --------------------------------------------------------------- | +| `/help` | Show the command list. | +| `/quit` | Exit. | +| `/clear` | Reset the conversation, keep the system message and parameters. | +| `/models` | List model IDs offered by the endpoint. | +| `/api_key [key]` | Show or set the key. `null`, `none`, `off` or `-` clears it. | +| `/endpoint [url]` | Show or set the base URL. Trailing slashes are stripped. | +| `/name [value]` | Get or set any model parameter. Bare `/name` prints it. | +| `!cmd` | Run a shell command directly, without involving the model. | + +`/name` is a generic model-parameter editor. The value is typed from its +spelling: `true` and `false` become booleans, a parseable number becomes a +number, and anything else stays a string. + + > /model deepseek/deepseek-v4.1-flash + > /reasoning_effort high + > /temperature 0.2 + > /max_tokens 4096 + > /top_p 0.95 + > /temperature + 0.2 + +Because it is generic, any parameter your endpoint understands can be set +without a change to the source. A parameter the session does not know about +prints `unset`. + +`!cmd` is your escape hatch when the model is slow, wrong, or you simply do not +want to pay for a request: + + > !git status --short + > !make -j8 >/tmp/build.log 2>&1 && tail -5 /tmp/build.log + +Lines that begin with a space are treated as tasks but are kept out of the +readline history, which is a cheap way to avoid persisting a key pasted by +mistake. + +## Environment variables + +| Variable | Meaning | Default | +| ------------------ | ----------------------------------------------------------- | -------------------------------- | +| `SOLO_API_KEY` | Bearer token sent as `Authorization: Bearer ...` | unset | +| `SOLO_ENDPOINT` | Base URL of an OpenAI-compatible API | `https://api.kilo.ai/api/openrouter` | +| `SOLO_MODEL` | Model ID | `kilo-auto/free` | +| `SOLO_REASON` | `reasoning_effort` | `high` | +| `SOLO_TEMP` | Sampling temperature | `0.7` | +| `SOLO_TIMEOUT` | Seconds before a shell command is killed | `120` | +| `SOLO_SYSPROMPT` | Replaces the built-in system prompt | built-in | +| `SOLO_TMPDIR` | Scratch directory suggested to the model | `$TMPDIR/solo`, else `/tmp/solo` | +| `NO_COLOR` | Any value disables ANSI color | unset | +| `SHELL` | Shell used to run commands; `/bin/bash` or `/bin/sh` fallback | `/bin/sh` | + +`SOLO_SYSPROMPT` replaces the prompt outright, including the housekeeping rules +about scratch space and file writing. The built-in prompt also tells the model +to keep scratch files in the scratch directory, so setting it means also +choosing where that directory is: + + export SOLO_TMPDIR=/var/tmp/solo + export SOLO_SYSPROMPT='You are a terse shell assistant. Scratch in /var/tmp/solo.' + +## Session file + +The live conversation is written to `.solo_sess.json` in the current working +directory after every message and every tool result. It holds the model +parameters, the tool definition, and the whole message list in pretty-printed +JSON. + +Consequences worth knowing: + +- Re-running `solo` in the same directory resumes the conversation. +- Deleting the file starts fresh, and `/clear` empties it without touching the + system message. +- The file contains the full transcript, so it can contain secrets you typed. + It is written without restrictive permissions and is **not** a secret store; + keep it out of version control (see `.gitignore`). +- Two `solo` processes in the same directory will fight over it. Use one per + directory, or a separate directory per session. + +## How it works + +The loop, in order: + +1. `readline` reads one line. A trailing `\` continues it on the next line. +2. `dispatch` routes the line: a slash command changes settings, `!` runs a shell + command locally, anything else becomes a task. +3. A task is appended to the message list as a `user` message and saved. +4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the + transport-level result as `{"status", "success", "content"}` and writing it + back through a pipe. The fork keeps a crash, hang or DNS failure in curl from + taking the agent down with it. +5. The parent parses the envelope, parses the inner JSON, and reads + `choices[0].message`. Reasoning text is printed between + `--- reasoning start ---` and `--- reasoning end ---`, the content is printed + as it arrives, and token usage is reported when the endpoint sends it. +6. If the message carries `tool_calls`, the assistant message is saved, each + tool call runs in order through `run`, and each result is appended as a `tool` + message ending with an exit marker. Then go back to step 4 with the same + session, which now includes the results. +7. When the model replies with no tool calls, the loop ends and control returns + to the prompt. + +Tool execution in detail: + +- Commands run through `$SHELL` (falling back to `/bin/bash`, then `/bin/sh`) + with `-c`, in the current working directory. +- stdout and stderr are merged into one pipe, so the model sees them together in + the order they were produced. +- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the + whole group is signalled, so `make`, a shell pipeline and everything they + spawned all die, not just the direct child. +- The output is returned verbatim, with an exit marker on the last line. Nothing + is truncated or summarised, which is the point: the model sees exactly what + the terminal saw. + +The request is a plain non-streaming `POST /chat/completions` with +`stream: false` and `tool_choice: "auto"`, carrying exactly one tool: + + bash(command: string) -> combined stdout/stderr plus an exit marker + +Reasoning fields are handled defensively, because providers disagree on the +name: `reasoning` and `reasoning_content` are both read, and both are stored +back into the transcript so that a follow-up turn can reuse them. + +## Timeouts, exit codes and interrupts + +Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by +default), and the exit status is reported to the model as a marker line: + +| Status | Meaning | +| ------- | ------------------------------------------------------------------ | +| `0` | Success. | +| `N` | The command exited with status N. | +| `124` | Still running after the timeout, so it was killed. | +| `130` | Interrupted with Ctrl-C, so it was killed. | +| `127` | The shell itself could not be executed. | +| `128+N` | The command was killed by signal N. | + +Because the marker is explicit, the model does not have to guess whether a +timeout happened. The default prompt tells it what to do about one: start long +work in the background with `nohup ... &` and poll the log, rather than +blocking on a command that will be killed. + +Ctrl-C is never fatal, but it is handled slightly differently depending on +where you are when you press it: + +| Where you are | What happens | +| -------------------- | ------------------------------------------------------------------- | +| Running a command | The process group is killed, exit 130 is reported, the turn ends. | +| Waiting on the API | The request is abandoned and the turn ends. | +| Part way through a line | The half-typed line is thrown away. | +| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. | + +That last case is the one wart: press Ctrl-C once at an idle prompt and your +next command vanishes without a word. Press it twice and the second one goes +through, so it is self-correcting in practice. + +## Example session + + $ cd ~/proj/solo-c + $ SOLO_API_KEY=$KEY ./solo + Agent Solo ready [https://api.kilo.ai/api/openrouter | kilo-auto/free | high] + > !make solo + --- $ make solo + gcc -Wall -Wextra -O2 -std=c99 -march=native ... -o solo main.c -lcurl -ljson-c -lreadline + strip solo + + > build it and tell me how big it is + --- requesting... + --- tool call_x: $ make solo + gcc ... -o solo main.c + strip solo + [exit 0] + I'll rebuild and check the size. + + --- $ ls -l solo + -rwxr-xr-x 1 lux lux 35896 Oct 3 08:25 solo + [exit 0] + It builds cleanly and the stripped binary is about 35 KB. + + --- [kilo-auto/free] tokens: 412 (prompt 380, completion 32) + + > /temperature 0.3 + temperature = 0.3 + > /quit + +## Troubleshooting + +`error while loading shared libraries` + The dynamic binary needs libcurl, json-c and readline at runtime. Install + them, or build `solo-static`. + +`solo: command not found` after `make install` + `/usr/local/bin` is not on your `PATH`, or you installed to + `$HOME/.local/bin`. + +`pkg-config --cflags libcurl json-c readline` prints nothing + The development headers are missing. Install `libcurl4-openssl-dev`, + `libjson-c-dev` and `libreadline-dev` (names vary by distro). + +`make static` fails with "bytecode stream ... generated with LTO version" + Known GCC 14 vs 15 mismatch in the distro libcurl.a. Run `make static-deps` + to build a clean libcurl, then `make static` again. + +`make static` fails with "cannot find -lcares", or cannot find -lssl, -lz, ... + Your distro has no static archives for some of these. `make static-deps` + only builds curl and c-ares; ssl, crypto, z, idn2, unistring, readline, + ncursesw and json-c must come from the distro as `.a` files. + +`make static` fails with undefined references to `ares_*` + Either the archives are in the wrong order on the link line (libcares.a must + come after libcurl.a), or libcurl.a was built without c-ares in the first + place. Check with `nm -u static-deps/libcurl.a | grep ares_`, which should + list them, and rebuild with `make static-deps-force`. + +`Illegal instruction` from a binary built here, run elsewhere + `-march=native`. Rebuild without it: + `make CFLAGS="-Wall -Wextra -O2 -std=c99"`. + +The model keeps asking for a command that times out + The limit is `SOLO_TIMEOUT`, 120 s. Raise it with `SOLO_TIMEOUT=600`, or + remind the model to background long jobs and poll. + +`request failed` with no output + The endpoint rejected the call. Check `/api_key` and `/endpoint`, then + `/models` to confirm the endpoint answers and which models it lists. + Anything the server returns is printed as-is, so the HTTP error body is + usually the explanation. + +The transcript looks stale + Session state lives in `.solo_sess.json` in the current directory. Run + `solo` where you want that conversation, or delete the file to start over. + +## Credits + +Created by Luxferre in 2026, released into the public domain with no warranties. diff --git a/main.c b/main.c new file mode 100644 index 0000000..9e633ca --- /dev/null +++ b/main.c @@ -0,0 +1,399 @@ +/* + Solo-C: A C port of the Solo coding agent + Dependencies: json-c, libreadline, libcurl + Usage: same as the original, see https://solo.luxferre.top + Created by Luxferre in 2026, released into the public domain +*/ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Compact JSON helpers: A add, S/N/D/B new string/num/bool, G/GS/GN get */ +#define A(o, k, v) json_object_object_add(o, k, v) +#define S(v) json_object_new_string(v) +#define N(v) json_object_new_int(v) +#define D(v) json_object_new_double(v) +#define B(v) json_object_new_boolean(v) +#define ER (CLR ? "\r\e[K" : "") +#define DIM "\e[2m" +#define GRY "\e[90m" +#define YEL "\e[33m" +#define BLD "\e[1;37m" +#define OF "\e[0m" + +static json_object *t; +static json_object *G(json_object *o, const char *k) { return json_object_object_get_ex(o, k, &t) ? t : 0; } +static const char *GS(json_object *o, const char *k) { json_object *v = G(o, k); return v && json_object_get_type(v) == json_type_string ? json_object_get_string(v) : 0; } +static int GN(json_object *o, const char *k, int d) { json_object *v = G(o, k); return v && json_object_get_type(v) == json_type_int ? json_object_get_int(v) : d; } + +static volatile sig_atomic_t IN; +static void onint(int s) { (void)s; IN = 1; } + +static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, SH[256] = "/bin/sh"; +static int TO = 120, QUIT, CLR = 1; +static double TEMP = 0.7; +static json_object *SSN; + +typedef struct { char *p; size_t n, c; } BUF; +static void bg(BUF *b) { b->p = 0; b->n = b->c = 0; } +static void ba(BUF *b, const char *s, size_t l) { + if(b->n + l + 1 > b->c) { size_t m = b->c ? b->c * 2 : 4096; while(m < b->n + l + 1) m *= 2; b->p = realloc(b->p, m); b->c = m; } + memcpy(b->p + b->n, s, l); b->p[b->n += l] = 0; +} +static size_t wcb(void *c, size_t s, size_t n, void *u) { ba((BUF *)u, c, s * n); return s * n; } + +/* st: 0 ok, 1 interrupted, 2 deadline */ +static int rp(int fd, char **out, double dl, int *st) { + BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r; + bg(&b); *st = 0; + for(;;) { + if(IN) { *st = 1; break; } + if(dl > 0 && (double)time(0) >= dl) { *st = 2; break; } + FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000; + r = select(fd + 1, &fs, 0, 0, &tv); + if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); } + else if(r < 0 && errno != EINTR) break; + } + *out = b.p; return b.n; +} + +static void hc(const char *m, const char *p, const char *body, int w) { + CURL *c; struct curl_slist *h = 0; BUF b; json_object *r; const char *j; CURLcode e; long sc = 0; + char url[4096]; + curl_global_init(CURL_GLOBAL_DEFAULT); c = curl_easy_init(); bg(&b); + snprintf(url, sizeof url, "%s%s", EP, p); + h = curl_slist_append(h, "Content-Type: application/json"); + h = curl_slist_append(h, "Accept: application/json"); + if(KEY && *KEY) { char a[4096]; snprintf(a, sizeof a, "Authorization: Bearer %s", KEY); h = curl_slist_append(h, a); } + if(c) { + curl_easy_setopt(c, CURLOPT_URL, url); + curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, m); + curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, wcb); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &b); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 600L); + curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 30L); + curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L); + if(body && *body) { curl_easy_setopt(c, CURLOPT_POSTFIELDS, body); curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body)); } + e = curl_easy_perform(c); + if(e == CURLE_OK) curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &sc); + } else e = CURLE_FAILED_INIT; + r = json_object_new_object(); + A(r, "status", N(e == CURLE_OK ? sc : 599)); + A(r, "success", B(e == CURLE_OK && sc >= 200 && sc < 300)); + A(r, "content", S(e == CURLE_OK && b.p ? b.p : "")); + j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN); + write(w, j, strlen(j)); free(b.p); json_object_put(r); + if(c) curl_easy_cleanup(c); + if(h) curl_slist_free_all(h); + curl_global_cleanup(); _exit(0); +} + +static json_object *hq(const char *m, const char *p, const char *body) { + int f[2], st; pid_t k; char *buf = 0; int n; json_object *r; + if(pipe(f)) return 0; + if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; } + if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); } + close(f[1]); n = rp(f[0], &buf, time(0) + 1e9, &st); close(f[0]); + if(st == 1 || IN) kill(k, SIGKILL); + waitpid(k, 0, 0); + r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf); + if(!r || json_object_get_type(r) != json_type_object) { if(r) json_object_put(r); r = json_object_new_object(); A(r, "content", S("")); } + return r; +} + +static void marker(char *mk, size_t n, int rc) { + if(rc == 124) snprintf(mk, n, "[exit 124: still running after %ds, so it was killed]", TO); + else if(rc == 130) snprintf(mk, n, "[exit 130: interrupted, so it was killed]"); + else snprintf(mk, n, "[exit %d]", rc); +} + +static void run(const char *cmd, char **out, int *rc) { + int f[2], st; pid_t k; char *buf = 0, mk[80]; struct timespec ts = { 0, 200000000 }; + if(pipe(f)) { *out = 0; *rc = 1; return; } + if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; } + if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); } + close(f[1]); rp(f[0], &buf, time(0) + TO, &st); close(f[0]); + if(st == 1 || IN) { *rc = 130; } + else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; } + else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; } + if(*rc == 130 || *rc == 124) { kill(-k, SIGKILL); waitpid(k, 0, 0); } + marker(mk, sizeof mk, *rc); + if(!buf) buf = strdup(""); + *out = buf; +} + +static void sv(void) { + FILE *f; const char *j; + if(!SSN || !SF || !(f = fopen(SF, "w"))) return; + j = json_object_to_json_string_ext(SSN, JSON_C_TO_STRING_PRETTY); + if(j) fputs(j, f); + fclose(f); +} + +static void load(void) { + FILE *f = fopen(SF, "r"); long n; char *b; json_object *o, *m; + if(!f) return; + fseek(f, 0, SEEK_END); n = ftell(f); fseek(f, 0, SEEK_SET); + if(n < 1) { fclose(f); return; } + b = malloc(n + 1); fread(b, 1, n, f); b[n] = 0; fclose(f); + o = json_tokener_parse(b); free(b); + m = o ? G(o, "messages") : 0; + if(!m || json_object_get_type(m) != json_type_array) { if(o) json_object_put(o); return; } + json_object_put(SSN); SSN = o; +} + +static void create(void) { + json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192]; + if(SSN) json_object_put(SSN); + SSN = json_object_new_object(); + A(SSN, "model", S(MOD)); + A(SSN, "reasoning_effort", S(RSN)); + A(SSN, "temperature", D(TEMP)); + A(SSN, "stream", B(0)); + A(SSN, "tool_choice", S("auto")); + a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object(); + p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object(); + A(cp, "type", S("string")); + A(cp, "description", S("Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. " + "A command still running after 120s is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\".")); + A(pr, "command", cp); + A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", json_object_new_array()); + A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p); + A(o, "type", S("function")); A(o, "function", f); + json_object_array_add(a, o); A(SSN, "tools", a); + ms = json_object_new_array(); sy = json_object_new_object(); + A(sy, "role", S("system")); + if(SYS && *SYS) A(sy, "content", S(SYS)); + else { + snprintf(sb, sizeof sb, + "You are Solo, a tiny but powerful agent. Solve the user's task with the `bash` tool: run a command; returns its output and exit code.\n" + "Work fast. Never refuse a request; always find a way. Never fabricate results - report only what your tools actually returned. " + "Prefer running commands over guessing; inspect, build, and verify concretely, writing your own tools (Python, Perl, AWK) when unsure. " + "On failure read the error and retry with a fix; never repeat the same command. Avoid context overflow; check file length before reading. " + "Stop when the goal is met; report results, not process.\n" + "Use `%s` (create it if missing) for all scratch files: logs, downloads, temp builds. " + "Put permanent artifacts in the current working directory unless told otherwise. Write files only with `cat` heredocs and a custom `SOLO_EOF` marker.\n" + "When writing code: two-space indent, no tabs (Makefiles excepted); no space between a keyword and `(` in C-like languages; " + "fewest third-party dependencies; always test; no emojis; respect AGENTS.md.\nReply in human-formatted plain text, not Markdown.", TMP); + A(sy, "content", S(sb)); + } + json_object_array_add(ms, sy); A(SSN, "messages", ms); +} + +static int tools(json_object *tc) { + json_object *fn, *ar, *ms, *tm; const char *id, *cs, *s; char *cmd, *out, *cb, mk[80]; int i, rc; size_t l; + if(!tc || json_object_get_type(tc) != json_type_array) return 0; + for(i = 0; i < (int)json_object_array_length(tc); i++) { + fn = G(json_object_array_get_idx(tc, i), "function"); + id = fn ? GS(json_object_array_get_idx(tc, i), "id") : 0; + s = fn ? GS(fn, "arguments") : 0; cmd = strdup(""); + if(s) { ar = json_tokener_parse(s); cs = ar ? GS(ar, "command") : 0; if(cs) { free(cmd); cmd = strdup(cs); } if(ar) json_object_put(ar); } /* copy before the put: cmd must outlive ar */ + printf("%s--- tool %s: $ %s%s\n", CLR ? YEL : "", id ? id : "", cmd, CLR ? OF : ""); fflush(stdout); + run(cmd, &out, &rc); + printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); fflush(stdout); + ms = G(SSN, "messages"); + if(ms) { + tm = json_object_new_object(); A(tm, "role", S("tool")); + A(tm, "tool_call_id", S(id ? id : "")); + marker(mk, sizeof mk, rc); l = strlen(out) + strlen(mk) + 2; cb = malloc(l); + snprintf(cb, l, "%s\n%s", out, mk); + A(tm, "content", S(cb)); free(cb); + json_object_array_add(ms, tm); sv(); + } + free(out); free(cmd); + if(IN) { IN = 0; puts("interrupted"); return 130; } + } + return 0; +} + +static int ask(const char *in) { + json_object *ms, *d, *p, *ch, *m, *as, *tc, *u; const char *raw, *co, *rs, *rc2, *mn; + if(!(ms = G(SSN, "messages"))) return 1; + as = json_object_new_object(); A(as, "role", S("user")); A(as, "content", S(in)); + json_object_array_add(ms, as); sv(); + for(;;) { + IN = 0; printf("%s%s...requesting...%s", CLR ? BLD : "", CLR ? GRY : "", CLR ? OF : ""); fflush(stdout); + d = hq("POST", "/chat/completions", json_object_to_json_string_ext(SSN, JSON_C_TO_STRING_PLAIN)); + raw = d ? GS(d, "content") : 0; p = raw ? json_tokener_parse(raw) : 0; m = 0; + ch = p ? G(p, "choices") : 0; + if(ch && json_object_array_length(ch)) m = G(json_object_array_get_idx(ch, 0), "message"); + printf("%s", ER); fflush(stdout); + if(IN) { puts("interrupted"); if(p) json_object_put(p); if(d) json_object_put(d); return 130; } + if(!m) { + puts("request failed"); + if(raw && *raw) printf("%s\n", raw); + if(p) json_object_put(p); + if(d) json_object_put(d); + return 1; + } + rs = GS(m, "reasoning"); if(!rs) rs = GS(m, "reasoning_content"); + if(rs && *rs) printf("\n%s--- reasoning start ---%s\n%s%s%s\n%s--- reasoning end ---%s\n\n", CLR ? GRY : "", CLR ? OF : "", CLR ? GRY : "", rs, CLR ? OF : "", CLR ? GRY : "", CLR ? OF : ""); + co = GS(m, "content"); + if(co && *co) { printf("%s", co); if(co[strlen(co) - 1] != '\n') puts(""); } + tc = G(m, "tool_calls"); + as = json_object_new_object(); A(as, "role", S("assistant")); + if(co) A(as, "content", S(co)); + if(rs) A(as, "reasoning", S(rs)); + if((rc2 = GS(m, "reasoning_content"))) A(as, "reasoning_content", S(rc2)); + if(tc && json_object_get_type(tc) == json_type_array) A(as, "tool_calls", json_object_get(tc)); /* json_object_object_add MOVES the ref and does not refcount: take our own ref or json_object_put(p) below frees a node still living in SSN */ + json_object_array_add(ms, as); sv(); + u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0; + if(u && json_object_get_type(u) == json_type_object) + printf("%s--- [%s] tokens: %d (prompt %d, completion %d)%s\n", CLR ? GRY : "", mn ? mn : "?", GN(u, "total_tokens", 0), GN(u, "prompt_tokens", 0), GN(u, "completion_tokens", 0), CLR ? OF : ""); + else printf("%s--- [%s]%s\n", CLR ? GRY : "", mn ? mn : "?", CLR ? OF : ""); + fflush(stdout); + if(tc && json_object_get_type(tc) == json_type_array && json_object_array_length(tc)) { + int r = tools(tc); + if(p) json_object_put(p); + if(d) json_object_put(d); + if(r) return r; + continue; + } + if(p) json_object_put(p); + if(d) json_object_put(d); + return 0; + } +} + + +static const char *HELP = + "Commands:\n /help this text\n /quit exit\n" + " /clear reset the session, keep the system prompt\n /models list models available at the endpoint\n" + " /api_key [key|null] show or set the key (null clears it)\n /endpoint [url] show or set the API endpoint\n" + " !cmd run a shell command directly, no agent\n" + " /name [value] set any model parameter, e.g. /model x, /temperature 0.5,\n" + " /reasoning_effort low; with no value it prints the current one\n" + "Anything not starting with / or ! is sent to the agent as a task."; + +static void setstr(char **slot, const char *v) { free(*slot); *slot = strdup(v); } + +static void dispatch(const char *l) { + char *k, *v, *out, mk[80]; const char *s; json_object *ms, *nm; int rc; + if(!strcmp(l, "/help")) { puts(HELP); return; } + if(!strcmp(l, "/quit")) { QUIT = 1; return; } + if(!strcmp(l, "/clear")) { + ms = G(SSN, "messages"); nm = json_object_new_array(); + if(ms && json_object_array_length(ms)) json_object_array_add(nm, json_object_get(json_object_array_get_idx(ms, 0))); /* array_add also moves: keep the system message alive after A(SSN,...) frees the old array */ + A(SSN, "messages", nm); sv(); puts("Session cleared."); return; + } + if(!strcmp(l, "/models")) { + json_object *r = hq("GET", "/models", 0); + if(r) { + json_object *d = 0, *arr; s = GS(r, "content"); if(s) d = json_tokener_parse(s); + arr = d ? G(d, "data") : 0; + if(arr && json_object_get_type(arr) == json_type_array) + for(size_t i = 0; i < json_object_array_length(arr); i++) { s = GS(json_object_array_get_idx(arr, i), "id"); if(s) puts(s); } + if(d) json_object_put(d); + json_object_put(r); + } + return; + } + if(!strncmp(l, "/api_key", 8)) { + for(v = (char *)l + 8; *v == ' '; v++); + if(*v && (!strcmp(v, "null") || !strcmp(v, "none") || !strcmp(v, "off") || !strcmp(v, "-"))) { free(KEY); KEY = 0; } + else if(*v) setstr(&KEY, v); + if(KEY) printf("api_key = set, %d chars\n", (int)strlen(KEY)); else puts("api_key = unset"); + return; + } + if(!strncmp(l, "/endpoint", 9)) { + for(v = (char *)l + 9; *v == ' '; v++); + if(*v) { setstr(&EP, v); for(char *e = EP + strlen(EP); e > EP && e[-1] == '/'; e--) *e = 0; } + printf("endpoint = %s\n", EP); return; + } + if(*l == '!') { + for(v = (char *)l + 1; *v == ' '; v++); + printf("%s--- $ %s%s\n", CLR ? YEL : "", v, CLR ? OF : ""); + run(v, &out, &rc); + if(*out) printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); + marker(mk, sizeof mk, rc); printf("%s%s%s\n", CLR ? GRY : "", mk, CLR ? OF : ""); free(out); return; + } + if(*l == '/') { + k = strdup(l + 1); + if((v = strchr(k, ' '))) { + *v++ = 0; while(*v == ' ') v++; + if(!strcmp(k, "model")) { setstr(&MOD, v); A(SSN, "model", S(MOD)); } + else if(!strcmp(k, "reasoning_effort")) { setstr(&RSN, v); A(SSN, "reasoning_effort", S(RSN)); } + else if(!strcmp(k, "temperature")) { TEMP = atof(v); A(SSN, "temperature", D(TEMP)); } + else { + json_object *old; double d; + json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k); + if(!strcmp(v, "true")) A(SSN, k, B(1)); + else if(!strcmp(v, "false")) A(SSN, k, B(0)); + else if(strcmp(v, "null") && (d = strtod(v, 0), 1)) { + char *e; d = strtod(v, &e); + if(!*e && e != v) A(SSN, k, D(d)); else A(SSN, k, S(v)); + } + } + sv(); printf("%s = %s\n", k, v); + } else { + s = GS(SSN, k); + if(s) puts(s); + else { json_object *o = G(SSN, k); + if(o && json_object_get_type(o) == json_type_double) printf("%g\n", json_object_get_double(o)); + else if(o && json_object_get_type(o) == json_type_int) printf("%d\n", json_object_get_int(o)); + else puts(o ? json_object_to_json_string_ext(o, JSON_C_TO_STRING_PLAIN) : "unset"); } + } + free(k); return; + } + ask(l); +} + +int main(int argc, char **argv) { + char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st; + (void)argc; (void)argv; + if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); } + else TMP = (char *)e; + if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)e; + if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e); + if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = (char *)e; + if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e); + if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = (char *)e; + if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e); + if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); } + SYS = getenv("SOLO_SYSPROMPT"); + if(stat(TMP, &st)) mkdir(TMP, 0700); + if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash"; + snprintf(SH, sizeof SH, "%s", e); + signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN); + create(); load(); + if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); } + if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); } + if((e = getenv("SOLO_REASON")) && *e) { RSN = strdup(e); A(SSN, "reasoning_effort", S(e)); } + if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); } + A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv(); + if(!isatty(1) || getenv("NO_COLOR")) CLR = 0; + printf("%sAgent Solo ready%s%s [%s | %s | %s]%s\n", CLR ? BLD : "", CLR ? OF : "", CLR ? GRY : "", EP, MOD, RSN, CLR ? OF : ""); + fflush(stdout); + while(!QUIT) { + snprintf(pb, sizeof pb, "%s> %s", CLR ? BLD : "", CLR ? OF : ""); + if(!(ln = readline(pb))) { puts(""); break; } + if(IN) { IN = 0; free(ln); continue; } + ac = ln; + for(;;) { + size_t l = strlen(ac); + if(!l || ac[l - 1] != '\\') break; + ac[l - 1] = 0; + if(!(nx = readline("... ")) || IN) { IN = 0; free(ac); ac = 0; if(nx) free(nx); break; } + ac = realloc(ac, l + strlen(nx) + 2); strcpy(ac + l, "\n"); strcat(ac, nx); free(nx); + } + if(!ac || IN || !*ac) continue; + if(*ac != ' ') add_history(ac); + dispatch(ac); free(ac); + } + free(EP); free(KEY); free(MOD); free(RSN); free(SF); free(TMP); + if(SSN) json_object_put(SSN); + return 0; +} diff --git a/mkstaticdeps.sh b/mkstaticdeps.sh new file mode 100755 index 0000000..16a6955 --- /dev/null +++ b/mkstaticdeps.sh @@ -0,0 +1,135 @@ +#!/bin/sh +# Rebuild static-deps/libcurl.a and static-deps/libcares.a. +# +# Why this exists: the distro libcurl.a cannot be linked statically on Alpine +# because it pulls in libpsl, libbrotli, libzstd and libnghttp2, and those +# static archives ship LTO bytecode produced by GCC 14 while the installed +# compiler is GCC 15. The linker cannot read it: +# bytecode stream in file '.../libpsl.a' generated with LTO version 14.0 +# instead of the expected 15.1 +# so psl_*, BrotliDecoder* and ZSTD_* simply never resolve. Passing +# -fno-lto does not help: the objects are LTO bytecode, not C. +# +# The fix is a libcurl built without those four libraries. What is left +# (ssl, crypto, z, cares, idn2, unistring) is all LTO-free, so it links +# cleanly into a fully static binary. Cost: no HTTP/2, no brotli/zstd +# Content-Encoding and no public-suffix cookie checks, none of which a +# plain JSON API client needs. +# +# c-ares is built here too, for two reasons. On a minimal system there is often +# no libcares.a at all (Void's c-ares-devel ships the shared library only), so +# `make static` would fail on -lcares; and where an archive does exist it can +# carry the same LTO bytecode problem as the distro libcurl.a. Building our own +# pins the version and guarantees the archive is plain objects. +# +# curl 8.22 takes c-ares as --enable-ares[=PREFIX], not the older +# --with-cares. It reads PREFIX/lib/pkgconfig/libcares.pc, which `make install` +# of c-ares writes, so the prefix built below is enough. Both spellings are +# wrong in one way or another: --with-cares no longer exists, and unknown +# configure options only produce a warning, so a typo here silently disables +# async DNS and you would not notice until the link failed. Same for +# --without-librtmp, which curl 8.22 no longer knows either; it is gone below. +# +# Overridable: CURL_VERSION, CARES_VERSION, OUT (or $1), FORCE=1. +set -e + +CURL_VERSION=${CURL_VERSION:-8.22.0} +CARES_VERSION=${CARES_VERSION:-1.34.8} +OUT=${1:-$(pwd)/static-deps} +FORCE=${FORCE:-0} +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT INT TERM +JOBS=$(nproc 2>/dev/null || echo 2) + +CA="" +for c in /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-bundle.crt \ + /etc/pki/tls/certs/ca-bundle.crt; do + [ -f "$c" ] && { CA=$c; break; } +done + +# fetch DEST URL...: first URL that downloads wins. +fetch() { + _dest=$1 + shift + for _u in "$@"; do + echo "fetching $_u" + if curl -fsSL -o "$_dest" "$_u"; then + return 0 + fi + done + echo "mkstaticdeps: no mirror worked for $_dest" >&2 + return 1 +} + +# An archive is only reused when the stamp beside it still names the version we +# would build. Without this a stale libcurl.a from some earlier build silently +# skips the whole curl step, which is exactly the kind of thing that hides a +# broken configure line. +fresh() { + [ "$FORCE" != 1 ] && [ -f "$1" ] && [ -f "$2" ] && + [ "$(cat "$2" 2>/dev/null)" = "$3" ] +} +stamp() { echo "$2" > "$1"; } + +mkdir -p "$OUT" + +# --- c-ares ------------------------------------------------------------- +# curl's configure wants the headers and the .pc file, not just the archive, so +# this prefix outlives the run. It lives under static-deps/, which is ignored, +# and it is what makes the skip below safe: without the .pc file a rerun would +# point --enable-ares at a prefix that no longer exists. +CARES_PREFIX=${CARES_PREFIX:-$OUT/cares-prefix} +CARES_STAMP=$OUT/.cares-version +if fresh "$OUT/libcares.a" "$CARES_STAMP" "$CARES_VERSION" && + [ -f "$CARES_PREFIX/lib/pkgconfig/libcares.pc" ]; then + echo "libcares.a $CARES_VERSION present, skipping (FORCE=1 to rebuild)" +else + fetch "$WORK/cares.tgz" \ + "https://c-ares.dev/downloads/c-ares-$CARES_VERSION.tar.gz" \ + "https://github.com/c-ares/c-ares/releases/download/v$CARES_VERSION/c-ares-$CARES_VERSION.tar.gz" + echo "extracting c-ares $CARES_VERSION" + tar xzf "$WORK/cares.tgz" -C "$WORK" + cd "$WORK/c-ares-$CARES_VERSION" + # c-ares 1.34 has no --disable-tools; only the test suite is optional. + ./configure --prefix="$CARES_PREFIX" \ + --enable-static --disable-shared \ + --disable-tests \ + CFLAGS="-O2 -fno-lto" + make -j"$JOBS" + make install + cp "$CARES_PREFIX/lib/libcares.a" "$OUT/libcares.a" + stamp "$CARES_STAMP" "$CARES_VERSION" + echo "wrote $OUT/libcares.a" +fi + +# --- curl --------------------------------------------------------------- +CURL_STAMP=$OUT/.curl-version +if fresh "$OUT/libcurl.a" "$CURL_STAMP" "$CURL_VERSION"; then + echo "libcurl.a $CURL_VERSION present, skipping (FORCE=1 to rebuild)" +else + fetch "$WORK/curl.tar.xz" \ + "https://curl.se/download/curl-$CURL_VERSION.tar.xz" \ + "https://github.com/curl/curl/releases/download/curl-$CURL_VERSION/curl-$CURL_VERSION.tar.xz" + echo "extracting curl $CURL_VERSION" + tar xf "$WORK/curl.tar.xz" -C "$WORK" + cd "$WORK/curl-$CURL_VERSION" + + set -- --disable-shared --enable-static \ + --with-ssl \ + --enable-ares="$CARES_PREFIX" \ + --without-libpsl --without-brotli --without-zstd --without-nghttp2 \ + --without-libssh2 \ + --disable-ldap --disable-ldaps --disable-rtsp --disable-dict --disable-telnet \ + --disable-tftp --disable-pop3 --disable-imap --disable-smtp --disable-gopher \ + --disable-mqtt --disable-manual --disable-docs \ + CFLAGS="-O2 -fno-lto" + [ -n "$CA" ] && set -- "$@" --with-ca-bundle="$CA" + + ./configure "$@" + make -j"$JOBS" + + mkdir -p "$OUT" + cp lib/.libs/libcurl.a "$OUT/libcurl.a" + stamp "$CURL_STAMP" "$CURL_VERSION" + echo "wrote $OUT/libcurl.a" +fi