# Minimal CLI Agent in C

> Source: <https://gist.github.com/fourlexboehm/a60e4ef9306744483731cd176bd02e9f>
> Published: 2026-08-08 05:25:10+00:00

| // This is an entire CLI agent, it can do everything Claude code can do, using raw mode to support pasting, backspace, | |
| // escape to cancel agents turn, and multiline prompts with Shift + enter, | |
| // but with 2MB of RAM, neglibible CPU usage and one small C file. | |
| // It is a single C file for the openai responses api, with no dependency other than libcurl, and an | |
| // OPENAI_API_KEY env var. | |
| // compile with `cc -lcurl -o cgent this_file.c` then install where you please. | |
| // AI USAGE - This code was written without AI agents, largely to maintain my c skills, as such it is likely to have | |
| // memory safety bugs. | |
| #include <ctype.h> | |
| #include <curl/curl.h> | |
| #include <stdio.h> | |
| #include <string.h> | |
| #include <stdlib.h> | |
| #include <unistd.h> | |
| #include <termios.h> | |
| #include <stdbool.h> | |
| #define BUF_SIZE 65536 | |
| #define INPUT_SIZE (1024 * 1024 + 4096) | |
| // A terminal read returns a keystroke or a paste burst, never a whole prompt. | |
| // take_prompt loops until stdin is drained and accumulates into prompt_buffer, | |
| // so this only bounds a single read(2). | |
| #define READ_SIZE 4096 | |
| // Escaping can double every byte, so this bounds a prompt of PROMPT_SIZE / 2. | |
| // It is never written past what is typed, so untouched pages stay unmapped. | |
| #define PROMPT_SIZE (1024 * 1024) | |
| #define CARRY_SIZE 65536 | |
| #define RESPONSE_ID_LEN 55 | |
| // Longest marker we search for; a chunk's final MARKER_MAX bytes are carried | |
| // over, so a marker split across chunks is seen on the next pass. | |
| #define MARKER_MAX 40 | |
| static const char MODEL[] = "gpt-5.6-luna"; | |
| // Interpolated raw, so no quotes or newlines. Sent every request: previous_response_id | |
| // does not carry instructions over, and a prefix that only sometimes holds them misses. | |
| static const char INSTRUCTIONS[] = | |
| "You are an AI agent, you have access to one tool, bash, use it in this " | |
| "directory to see what's here yourself to answer solve my problems."; | |
| static const char TOOLS_JSON[] = | |
| ",\"tools\":[{\"type\":\"function\",\"name\":\"bash\"," | |
| "\"description\":\"Run a bash command, returns stdout and stderr.\"," | |
| "\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}}," | |
| "\"required\":[\"command\"],\"additionalProperties\":false},\"strict\":true}]"; | |
| static int tool_call_count; | |
| static int input_len; | |
| static char input_buffer[INPUT_SIZE]; | |
| // The response body is parsed in place as it arrives and never accumulated. Only | |
| // the tail that might hold a marker split across a chunk boundary, or a tool call | |
| // that has not fully arrived, is carried over to the next chunk. | |
| static char carry[CARRY_SIZE]; | |
| static int carry_len; | |
| static bool in_text, text_esc, saw_text; | |
| // The id arrives mid-stream, but chaining from a cancelled turn leaves a function_call | |
| // with no output, which the next request rejects. Committed only on completion. | |
| static char previous_response_id[RESPONSE_ID_LEN + 1], pending_response_id[RESPONSE_ID_LEN + 1]; | |
| struct termios orig_termios; | |
| void wout(const char *str, int len) { | |
| write(STDOUT_FILENO, str, len); | |
| } | |
| static void erase_previous(void) { | |
| static const char erase_previous[] = "\x1b[1D \x1b[1D"; | |
| wout(erase_previous, sizeof(erase_previous) - 1); | |
| } | |
| static void safe_exit(void) { | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); | |
| } | |
| static void enable_raw_mode(void) { | |
| tcgetattr(STDIN_FILENO, &orig_termios); | |
| struct termios raw = orig_termios; | |
| raw.c_lflag &= ~(ECHO | ICANON); | |
| raw.c_iflag &= ~ICRNL; | |
| raw.c_cc[VMIN] = 1; | |
| raw.c_cc[VTIME] = 0; | |
| atexit(safe_exit); | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); | |
| } | |
| /* Return the number of characters on the last (logical) line. The prompt | |
| * buffer contains a JSON string body, so a newline is stored as "\\n" and a | |
| * quote/backslash may occupy two bytes. */ | |
| static int get_line_index(const char *prompt_buffer, const char *prompt_end) { | |
| int line_index = 0; | |
| bool first_line = true; | |
| while (prompt_buffer < prompt_end) { | |
| if (*prompt_buffer == '\\' && prompt_buffer + 1 < prompt_end) { | |
| if (prompt_buffer[1] == 'n') { | |
| line_index = 0; | |
| prompt_buffer += 2; | |
| first_line = false; | |
| continue; | |
| } | |
| if (prompt_buffer[1] == '"' || prompt_buffer[1] == '\\') { | |
| line_index++; | |
| prompt_buffer += 2; | |
| continue; | |
| } | |
| } | |
| line_index++; | |
| prompt_buffer++; | |
| } | |
| return line_index + first_line; | |
| } | |
| static void take_prompt(char *read_buffer, char *prompt_buffer) { | |
| int line_index = 0; | |
| char *prompt_pos = prompt_buffer; | |
| *prompt_pos = 0; | |
| for (int bytes_read; | |
| (bytes_read = read(STDIN_FILENO, read_buffer, READ_SIZE - 1)) >= 0;) { | |
| // read_buffer is compared as a C string below, and is not zeroed between | |
| // reads, so terminate what actually arrived. | |
| read_buffer[bytes_read] = 0; | |
| for (int i = 0; i < bytes_read; i++) { | |
| char c = read_buffer[i]; | |
| // No token appends more than two bytes plus the terminator. Once | |
| // full, drop input rather than run off the end; editing keys still | |
| // work, so the prompt can be cut back down. | |
| bool full = prompt_pos - prompt_buffer + 3 > PROMPT_SIZE; | |
| if (!iscntrl(c) && read_buffer[0] != '\x1b' ) { | |
| if (full) continue; | |
| wout(&c, 1); | |
| switch (c) { | |
| // The buffer is interpolated straight into the request as a | |
| // JSON string, so escape here as newlines already are. | |
| case '"': | |
| case '\\': | |
| *prompt_pos++ = '\\'; | |
| } | |
| *prompt_pos++ = c; | |
| line_index++; | |
| } else { | |
| switch (c) { | |
| // backspace | |
| case 127: | |
| /* A newline is two bytes in the JSON buffer, but one | |
| * terminal line break. Keep the buffer and cursor in | |
| * sync when deleting it. */ | |
| if (prompt_pos == prompt_buffer) | |
| break; | |
| if (line_index == 0) { | |
| /* At column zero the previous token is an escaped | |
| * newline. The empty-buffer check prevents moving | |
| * above the first line. */ | |
| if (prompt_pos - prompt_buffer < 2 || | |
| prompt_pos[-2] != '\\' || prompt_pos[-1] != 'n') | |
| break; | |
| prompt_pos -= 2; | |
| *prompt_pos = 0; | |
| line_index = get_line_index(prompt_buffer, prompt_pos); | |
| /* Cursor is at the start of the current line. */ | |
| printf("\x1b[1F"); | |
| if (line_index > 0) | |
| printf("\x1b[%dC", line_index); | |
| fflush(stdout); | |
| break; | |
| } | |
| /* Quotes and backslashes have an extra JSON byte. */ | |
| if (prompt_pos - prompt_buffer >= 2 && | |
| prompt_pos[-2] == '\\' && | |
| (prompt_pos[-1] == '"' || prompt_pos[-1] == '\\')) | |
| prompt_pos -= 2; | |
| else | |
| prompt_pos--; | |
| *prompt_pos = 0; | |
| erase_previous(); | |
| line_index--; | |
| break; | |
| case '\r': | |
| //buffer | |
| line_index = 0; | |
| wout("\n", 1); | |
| if (bytes_read == i + 1) { | |
| *prompt_pos = 0; | |
| return; | |
| } | |
| if (full) break; | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| break; | |
| case '\n': | |
| //buffer | |
| line_index = 0; | |
| wout("\n", 1); | |
| if (full) break; | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| break; | |
| default: | |
| // Handle escape sequences, some terminals send this for shift + enter | |
| // Shift + Enter | |
| if (!strcmp(read_buffer, "\x1b[27;2;13~")) { | |
| if (!full) { | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| } | |
| wout("\n", 1); | |
| line_index = 0; | |
| goto buffer_parsed; | |
| } | |
| printf("%d\n", c); | |
| break; | |
| } | |
| } | |
| } | |
| buffer_parsed:; | |
| } | |
| // read(2) failed; the caller still needs a terminated buffer. | |
| *prompt_pos = 0; | |
| } | |
| static void wout_codepoint(unsigned int cp) { | |
| char b[4]; | |
| int n = 0; | |
| if (cp < 0x80) b[n++] = cp; | |
| else if (cp < 0x800) { | |
| b[n++] = 0xC0 | cp >> 6; | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } else if (cp < 0x10000) { | |
| b[n++] = 0xE0 | cp >> 12; | |
| b[n++] = 0x80 | (cp >> 6 & 0x3F); | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } else { | |
| b[n++] = 0xF0 | cp >> 18; | |
| b[n++] = 0x80 | (cp >> 12 & 0x3F); | |
| b[n++] = 0x80 | (cp >> 6 & 0x3F); | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } | |
| wout(b, n); | |
| } | |
| static enum { JSON_TEXT, JSON_ESC, JSON_HEX } unescape_state; | |
| static unsigned int unescape_cp, unescape_high; | |
| static int unescape_hexlen; | |
| static void reset_json_unescape(void) { | |
| unescape_state = JSON_TEXT; | |
| unescape_cp = unescape_high = 0; | |
| unescape_hexlen = 0; | |
| } | |
| // Unescapes a JSON string body straight to stdout. Holds only the few bytes of | |
| // escape state needed to resume, so it can be fed arbitrary chunk boundaries. | |
| static void print_json_unescaped(const char *src, int n) { | |
| for (int i = 0; i < n; i++) { | |
| char c = src[i]; | |
| switch (unescape_state) { | |
| case JSON_TEXT: | |
| if (c == '\\') unescape_state = JSON_ESC; | |
| else wout(&c, 1); | |
| break; | |
| case JSON_ESC: | |
| unescape_state = JSON_TEXT; | |
| switch (c) { | |
| case 'n': wout("\n", 1); break; | |
| case 't': wout("\t", 1); break; | |
| case 'r': wout("\r", 1); break; | |
| case 'b': wout("\b", 1); break; | |
| case 'f': wout("\f", 1); break; | |
| case 'u': | |
| unescape_cp = 0; | |
| unescape_hexlen = 0; | |
| unescape_state = JSON_HEX; | |
| break; | |
| default: wout(&c, 1); | |
| } | |
| break; | |
| case JSON_HEX: | |
| unescape_cp = unescape_cp * 16 + | |
| (c <= '9' ? c - '0' : (c | 32) - 'a' + 10); | |
| if (++unescape_hexlen < 4) break; | |
| unescape_state = JSON_TEXT; | |
| if (unescape_cp >= 0xD800 && unescape_cp < 0xDC00) { | |
| unescape_high = unescape_cp; | |
| break; | |
| } | |
| if (unescape_high && unescape_cp >= 0xDC00 && unescape_cp < 0xE000) | |
| unescape_cp = 0x10000 + ((unescape_high - 0xD800) << 10) + | |
| (unescape_cp - 0xDC00); | |
| unescape_high = 0; | |
| wout_codepoint(unescape_cp); | |
| } | |
| } | |
| } | |
| // Appends src to dst as an escaped JSON string body, returns the new length. | |
| static int json_escape(char *dst, int len, const char *src) { | |
| for (; *src && len + 2 < INPUT_SIZE; src++) { | |
| switch (*src) { | |
| case '"': case '\\': dst[len++] = '\\'; dst[len++] = *src; break; | |
| case '\n': dst[len++] = '\\'; dst[len++] = 'n'; break; | |
| case '\t': dst[len++] = '\\'; dst[len++] = 't'; break; | |
| default: if ((unsigned char)*src >= 0x20) dst[len++] = *src; | |
| } | |
| } | |
| return len; | |
| } | |
| static void feed(char *chunk, int len, bool final); | |
| static void run_agent(char *prompt, CURL *curl, CURL *multi) { | |
| static struct curl_waitfd input = { | |
| .fd = STDIN_FILENO, | |
| .events = CURL_WAIT_POLLIN, | |
| .revents = 0 | |
| }; | |
| struct termios raw_termios; | |
| tcgetattr(STDIN_FILENO, &raw_termios); | |
| char animation[] = "\x1b[1D!"; | |
| char c = 0; | |
| raw_termios.c_cc[VMIN] = 0; | |
| raw_termios.c_cc[VTIME] = 1; | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios); | |
| snprintf(input_buffer, INPUT_SIZE, "\"%s\"", prompt); | |
| do { | |
| curl_multi_add_handle(multi, curl); | |
| char *previous_json = NULL; | |
| if (*previous_response_id) | |
| asprintf(&previous_json, ", \"previous_response_id\":\"%s\"", previous_response_id); | |
| // gpt-5.6 needs prompt_cache_key for reliable matching: without it an identical | |
| // prefix routes by hash alone and lands elsewhere each turn. Every turn of a | |
| // session shares the prefix, so one key per process; a reused pid costs a miss. | |
| char *json; | |
| asprintf( | |
| &json, | |
| "{\"model\":\"%s\",\"instructions\":\"%s\",\"prompt_cache_key\":\"cgent:%d\"" | |
| ",\"input\":%s%s%s}", | |
| MODEL, INSTRUCTIONS, (int)getpid(), | |
| input_buffer, | |
| TOOLS_JSON, | |
| previous_json ? previous_json : "" | |
| ); | |
| //printf("%s\n", json); | |
| curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json); | |
| curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, strlen(json)); | |
| tool_call_count = carry_len = *pending_response_id = 0; | |
| in_text = text_esc = saw_text = false; | |
| int still_running; | |
| do { | |
| CURLMcode mc = curl_multi_perform(multi, &still_running); | |
| if (!mc && still_running && c != 27) { | |
| // extra_nfds must be 1, otherwise stdin is never polled, revents | |
| // is never set, and ESC cannot cancel a stalled request. | |
| mc = curl_multi_poll(multi, &input, 1, 80, NULL); | |
| } | |
| if (input.revents & CURL_WAIT_POLLIN) { | |
| read(STDIN_FILENO, &c, 1); | |
| input.revents = 0; | |
| } | |
| wout(animation, sizeof(animation) - 1); | |
| if (animation[4] == '/') animation[4] = '!'; | |
| animation[4]++; | |
| } while (still_running && c !=27); | |
| erase_previous(); | |
| // Drain the multi queue before removing the handle, otherwise a transport | |
| // failure is silently indistinguishable from an empty reply. | |
| CURLMsg *msg; | |
| for (int queued; (msg = curl_multi_info_read(multi, &queued));) | |
| if (msg->msg == CURLMSG_DONE && msg->data.result != CURLE_OK) | |
| printf("\x1b[31mcurl: %s\x1b[0m\n", | |
| curl_easy_strerror(msg->data.result)); | |
| if (c != 27) { | |
| long code = 0; | |
| curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); | |
| if (code && code != 200) { | |
| printf("\x1b[31mHTTP %ld\x1b[0m\n%s\n", code, json); | |
| exit(1); | |
| } | |
| feed(carry, 0, true); | |
| // Per iteration, not per turn: the tool loop attaches its output to this id. | |
| if (*pending_response_id) strcpy(previous_response_id, pending_response_id); | |
| if (!saw_text && !tool_call_count) | |
| printf("\x1b[31m[no text or tool call in response]\x1b[0m\n"); | |
| fflush(stdout); | |
| } | |
| curl_multi_remove_handle(multi, curl); | |
| free(json); | |
| free(previous_json); | |
| } while (tool_call_count && c != 27); | |
| raw_termios.c_cc[VMIN] = 1; | |
| raw_termios.c_cc[VTIME] = 0; | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios); | |
| } | |
| static void run_repl(CURL *curl, CURL *multi) { | |
| char *read_buffer = malloc(READ_SIZE); | |
| char *prompt_buffer = malloc(PROMPT_SIZE); | |
| for(;;) { | |
| wout(">", 1); | |
| take_prompt(read_buffer, prompt_buffer); | |
| run_agent(prompt_buffer, curl, multi); | |
| } | |
| free(read_buffer); | |
| free(prompt_buffer); | |
| } | |
| static void run_tool(char *id, char *id_end, char *src, char *end) { | |
| // `exec` with only redirections applies them to the whole shell, so this holds | |
| // however the command is structured. Detaching stdin is what matters: popen | |
| // inherits our raw-mode terminal, so anything that reads stdin (or prompts) | |
| // blocks the agent forever. 2>&1 also makes this match the tool description. | |
| static const char prefix[] = "exec </dev/null 2>&1\n"; | |
| static const int prefix_len = sizeof prefix - 1; | |
| // The prefix is reserved at the head of the buffer and the command parsed in | |
| // directly after it, so the shell gets one buffer with no second copy. | |
| static char shell_command[BUF_SIZE], output[BUF_SIZE]; | |
| memcpy(shell_command, prefix, prefix_len); | |
| char *command = shell_command + prefix_len; | |
| int n = 0; | |
| while (src + 2 < end && !(src[0] == '\\' && src[1] == '"' && src[2] == '}') && | |
| prefix_len + n + 1 < BUF_SIZE) { | |
| char c = *src++; | |
| if (c == '\\' && src < end && *src == '\\') { | |
| src++; | |
| c = *src++; | |
| if (c == '\\' && src < end) c = *src++; | |
| else if (c == 'n') c = '\n'; | |
| else if (c == 't') c = '\t'; | |
| else if (c == 'r') c = '\r'; | |
| } | |
| command[n++] = c; | |
| } | |
| command[n] = 0; | |
| erase_previous(); | |
| printf("\x1b[90m$ %s\x1b[0m\n", command); | |
| fflush(stdout); | |
| FILE *pipe = popen(shell_command, "r"); | |
| int out_len = 0; | |
| bool truncated = false; | |
| if (pipe) { | |
| for (;;) { | |
| int space = (int)sizeof(output) - 1 - out_len; | |
| if (space > 0) { | |
| int r = fread(output + out_len, 1, space, pipe); | |
| if (r <= 0) break; | |
| out_len += r; | |
| } else { | |
| // Keep draining so the child is not killed mid-write by SIGPIPE. | |
| char sink[4096]; | |
| if (fread(sink, 1, sizeof sink, pipe) <= 0) break; | |
| truncated = true; | |
| } | |
| } | |
| pclose(pipe); | |
| } | |
| output[out_len] = 0; | |
| wout(output, out_len); | |
| if (truncated) wout("\n[output truncated]\n", 20); | |
| if (!tool_call_count) { | |
| input_buffer[0] = '['; | |
| input_len = 1; | |
| } | |
| input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len, | |
| "%s{\"type\":\"function_call_output\",\"call_id\":\"%.*s\",\"output\":\"", | |
| tool_call_count++ ? "," : "", (int)(id_end - id), id); | |
| input_len = json_escape(input_buffer, input_len, output); | |
| if (truncated) | |
| input_len = json_escape(input_buffer, input_len, "\n[output truncated]"); | |
| input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "\"}"); | |
| snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "]"); | |
| } | |
| // Finds the unescaped closing quote of a JSON string body. text_esc carries the | |
| // "previous byte was a backslash" state across calls so a split escape sequence | |
| // is not mistaken for the terminator. | |
| static char *find_string_end(char *src, int n) { | |
| for (int i = 0; i < n; i++) { | |
| if (text_esc) { text_esc = false; continue; } | |
| if (src[i] == '\\') { text_esc = true; continue; } | |
| if (src[i] == '"') return src + i; | |
| } | |
| return NULL; | |
| } | |
| // Parses src[0..n) in place and returns the offset of the first byte that could | |
| // not be consumed yet; the caller carries [ret, n) into the next chunk. With | |
| // final set, everything is consumed. | |
| static int parse_chunk(char *src, int n, bool final) { | |
| int cur = 0; | |
| for (;;) { | |
| char *base = src + cur; | |
| char *end = src + n; | |
| int avail = n - cur; | |
| if (avail <= 0) return n; | |
| if (in_text) { | |
| // find_string_end carries its escape state, so text streams out with | |
| // no lookahead at all. | |
| char *stop = find_string_end(base, avail); | |
| print_json_unescaped(base, (stop ? stop : end) - base); | |
| if (!stop) return n; | |
| wout("\n", 1); | |
| in_text = false; | |
| cur = (stop - src) + 1; | |
| continue; | |
| } | |
| char *call = strnstr(base, "\"type\": \"function_call\"", avail); | |
| char *rid = strnstr(base, "\"id\": \"resp_", avail); | |
| char *text = strnstr(base, "\"text\": \"", avail); | |
| char *first = NULL; | |
| int kind = 0; | |
| if (call) { first = call; kind = 1; } | |
| if (rid && (!first || rid < first)) { first = rid; kind = 2; } | |
| if (text && (!first || text < first)) { first = text; kind = 3; } | |
| // No marker here, so one can only have been split across the boundary. | |
| // Everything before the last MARKER_MAX bytes is provably marker-free. | |
| if (!first) { | |
| if (final) return n; | |
| return avail > MARKER_MAX ? n - MARKER_MAX : cur; | |
| } | |
| if (kind == 1) { | |
| // Bound the field search to this output item so a truncated item | |
| // cannot borrow the next one's call_id. | |
| char *item_end = strnstr(first + 1, "\"type\": \"function_call\"", | |
| end - (first + 1)); | |
| if (!item_end) item_end = end; | |
| char *id = strnstr(first, "\"call_id\": \"", item_end - first); | |
| char *command = strnstr(first, "\"arguments\": \"{\\\"command\\\":\\\"", | |
| item_end - first); | |
| char *id_end = NULL, *command_end = NULL; | |
| if (id && command) { | |
| id += sizeof("\"call_id\": \"") - 1; | |
| command += sizeof("\"arguments\": \"{\\\"command\\\":\\\"") - 1; | |
| id_end = memchr(id, '"', item_end - id); | |
| command_end = strnstr(command, "\\\"}", item_end - command); | |
| } | |
| // Incomplete item: carry it whole and retry once the rest lands. | |
| if (!id_end || !command_end) | |
| return final ? n : first - src; | |
| run_tool(id, id_end, command, command_end + 3); | |
| cur = (command_end - src) + 3; | |
| continue; | |
| } | |
| if (kind == 2) { | |
| char *id = first + sizeof("\"id\": \"") - 1; | |
| // Wait until the whole id has arrived, otherwise the copy picks up | |
| // bytes that are not part of it yet. | |
| if (end - id < RESPONSE_ID_LEN) | |
| return final ? n : first - src; | |
| strncpy(pending_response_id, id, RESPONSE_ID_LEN); | |
| cur = (id - src) + RESPONSE_ID_LEN; | |
| continue; | |
| } | |
| // kind == 3: start of an output_text value. | |
| cur = (first - src) + sizeof("\"text\": \"") - 1; | |
| in_text = true; | |
| saw_text = true; | |
| text_esc = false; | |
| reset_json_unescape(); | |
| erase_previous(); | |
| } | |
| } | |
| // Parses a chunk together with whatever the last one carried over. Only the | |
| // carry is ever copied; the body itself is parsed straight out of curl's buffer. | |
| static void feed(char *chunk, int len, bool final) { | |
| char *src = chunk; | |
| int n = len; | |
| if (carry_len) { | |
| if (carry_len + len > CARRY_SIZE) { | |
| printf("\x1b[31m[tool call exceeds %d bytes]\x1b[0m\n", CARRY_SIZE); | |
| exit(1); | |
| } | |
| memcpy(carry + carry_len, chunk, len); | |
| src = carry; | |
| n = carry_len + len; | |
| } | |
| int keep = n - parse_chunk(src, n, final); | |
| if (keep) memmove(carry, src + n - keep, keep); | |
| carry_len = keep; | |
| } | |
| static size_t write_mem_callback(void *contents, size_t size, size_t nmemb, void *userp) { | |
| (void)userp; | |
| int len = size * nmemb; | |
| feed(contents, len, false); | |
| return len; | |
| } | |
| int main(int argc, char **argv) { | |
| (void)argc; | |
| (void)argv; | |
| CURL *multi = curl_multi_init(); | |
| CURL *curl = curl_easy_init(); | |
| if (!curl) { | |
| curl_easy_cleanup(curl); | |
| exit(-1); | |
| } | |
| curl_easy_setopt(curl, CURLOPT_URL, "https://api.openai.com/v1/responses"); | |
| struct curl_slist *list = NULL; | |
| char *api_key = getenv("OPENAI_API_KEY"); | |
| if (api_key == NULL) return -1; | |
| char auth_header[] = "Authorization: Bearer \0 "; | |
| strcat(auth_header, api_key); | |
| list = curl_slist_append( | |
| list, | |
| auth_header | |
| ); | |
| list = curl_slist_append(list, "Content-Type: application/json"); | |
| curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list); | |
| curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem_callback); | |
| curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L); | |
| // No overall timeout: a long generation is legitimate. Give up only once the | |
| // connection has stopped delivering anything at all. | |
| curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); | |
| curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L); | |
| enable_raw_mode(); | |
| run_repl(curl, multi); | |
| curl_easy_cleanup(curl); | |
| } |
