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.
This commit is contained in:
Luxferre
2026-10-03 09:15:02 +03:00
commit 494bee8b77
5 changed files with 1102 additions and 0 deletions
+75
View File
@@ -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
+61
View File
@@ -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
+432
View File
@@ -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.
+399
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/select.h>
#include <curl/curl.h>
#include <json-c/json.h>
#include <readline/readline.h>
#include <readline/history.h>
/* 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;
}
+135
View File
@@ -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