summaryrefslogtreecommitdiffstats
path: root/src/config.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.cpp')
-rw-r--r--src/config.cpp732
1 files changed, 732 insertions, 0 deletions
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..4f3d00a
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,732 @@
+#include "config.h"
+
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <errno.h>
+#include <getopt.h>
+#include <limits>
+#include <sys/stat.h>
+#include <math.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "compact.h"
+#include "dotagents.h"
+#include "tools.h"
+
+#define DEFAULT_COMPACT_THRESHOLD 0.75
+#define DEFAULT_CLEAR_THRESHOLD 0.5
+#define DEFAULT_MAX_RETRIES 3UL
+#define DEFAULT_SKILL_INDEX_CAP 50UL
+#define DEFAULT_SHELL_TIMEOUT_SECS 120UL
+#define DEFAULT_MAX_OUTPUT_CHARS 16000UL
+#define DEFAULT_MAX_FILE_BYTES 2000000UL
+
+/* The default system preamble. The "Available tools" section is generated
+ * from the tool registry (tools.c) so it can't drift from the actual
+ * tool set. */
+static const char *PREAMBLE_HEAD =
+ "You are an agent operating inside a terminal. You accomplish tasks by "
+ "calling tools.\n"
+ "\n";
+static const char *PREAMBLE_TAIL =
+ "\n"
+ "Guidelines:\n"
+ "- Paths may be relative to the working directory or absolute.\n"
+ "- Inspect before you modify: use read_file, or run_shell with ls / "
+ "rg / grep, to understand existing state.\n"
+ "- Prefer small, verifiable steps. After making changes, verify them "
+ "(e.g. run the build or tests with run_shell).\n"
+ "- Keep run_shell commands safe and scoped to the task; avoid "
+ "destructive commands unless explicitly asked.\n"
+ "- When a task is complete, stop calling tools and reply with a short "
+ "summary of what you did.";
+
+char *default_preamble(void) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, PREAMBLE_HEAD);
+ char *tools = tool_preamble_list();
+ xbuf_put(&b, tools);
+ free(tools);
+ xbuf_put(&b, PREAMBLE_TAIL);
+ char *out = xbuf_steal(&b);
+ xbuf_free(&b);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Providers */
+/* ------------------------------------------------------------------ */
+
+provider_kind provider_parse(const char *raw, char **err) {
+ xbuf l;
+ xbuf_init(&l);
+ for (const char *p = raw; *p; p++)
+ xbuf_push(&l, (char)tolower((unsigned char)*p));
+ l.data[l.len] = '\0';
+ const char *s = l.data;
+ if (strcmp(s, "openai") == 0 || strcmp(s, "openai-compatible") == 0 ||
+ strcmp(s, "openai_compatible") == 0) {
+ xbuf_free(&l);
+ return PROV_OPENAI;
+ }
+ if (strcmp(s, "anthropic") == 0 || strcmp(s, "claude") == 0) {
+ xbuf_free(&l);
+ return PROV_ANTHROPIC;
+ }
+ if (strcmp(s, "ollama") == 0) {
+ xbuf_free(&l);
+ return PROV_OLLAMA;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "unknown provider `%s` (expected openai, anthropic, or "
+ "ollama)",
+ raw);
+ xbuf_free(&l);
+ char *r = xbuf_steal(&b);
+ if (err)
+ *err = r;
+ else
+ free(r);
+ return PROV_OPENAI;
+}
+
+const char *provider_name(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "openai";
+ case PROV_ANTHROPIC:
+ return "anthropic";
+ case PROV_OLLAMA:
+ return "ollama";
+ }
+ return "openai";
+}
+
+static const char *provider_debug(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "OpenAI";
+ case PROV_ANTHROPIC:
+ return "Anthropic";
+ case PROV_OLLAMA:
+ return "Ollama";
+ }
+ return "OpenAI";
+}
+
+const char *provider_default_model(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "gpt-5.2";
+ case PROV_ANTHROPIC:
+ return "claude-sonnet-4-6";
+ case PROV_OLLAMA:
+ return "llama3.2";
+ }
+ return "gpt-5.2";
+}
+
+/* ------------------------------------------------------------------ */
+/* CLI parsing */
+/* ------------------------------------------------------------------ */
+
+static void print_usage(FILE *f) {
+ fprintf(
+ f,
+ "llsh %s\n"
+ "llsh — a terminal LLM agent harness built on rig\n"
+ "\n"
+ "Usage: llsh [OPTIONS] [PROMPT]\n"
+ "\n"
+ "Arguments:\n"
+ " [PROMPT] One-shot prompt. If omitted (and stdin is a TTY), "
+ "launches the interactive REPL.\n"
+ "\n"
+ "Options:\n"
+ " --model <MODEL> Model name, e.g. `gpt-5.2`, "
+ "`claude-sonnet-4-6`, `llama3.2`; or a `.agents/models.json` "
+ "preset name (which also brings that preset's "
+ "provider/base_url/api_key). Overrides the preset's `model`; "
+ "defaults per provider when unset\n"
+ " --provider <PROVIDER> Model provider: `openai`, "
+ "`anthropic`, or `ollama`. `openai` also works against any "
+ "OpenAI-compatible endpoint via `--base-url`\n"
+ " --base-url <BASE_URL> Base URL override. For `openai` "
+ "this targets any OpenAI-compatible endpoint (LM Studio, vLLM, "
+ "Ollama's /v1, llama.cpp, ...); for `ollama` this is the Ollama "
+ "server address (default http://localhost:11434)\n"
+ " --api-key <API_KEY> API key. Overrides the preset's "
+ "`api_key`\n"
+ " -C, --cwd <CWD> Working directory the agent's file and shell "
+ "tools operate in\n"
+ " --preamble <PREAMBLE> System preamble for the agent\n"
+ " --max-turns <MAX_TURNS> Maximum model calls (including "
+ "tool-call continuations) per user turn. `0` (the default) is "
+ "unbounded\n"
+ " --max-retries <MAX_RETRIES> Number of times a transient "
+ "provider failure (timeout, 429, 5xx) is retried with exponential "
+ "backoff before the turn fails. `0` disables retry\n"
+ " --context-limit <CONTEXT_LIMIT> Model context window in "
+ "tokens. `0` (the default) disables context compaction; a window "
+ "reported by the provider's endpoint is adopted when available, "
+ "otherwise this value is used\n"
+ " --compact-threshold <COMPACT_THRESHOLD> Fraction of "
+ "`context_limit` at which earlier context is summarized. `0` "
+ "disables the tier; otherwise the value must be in `(0, 1]`\n"
+ " --clear-threshold <CLEAR_THRESHOLD> Fraction of "
+ "`context_limit` at which stale tool results are cleared. `0` "
+ "disables the tier; otherwise the value must be in `(0, 1]`\n"
+ " --no-rubric-compaction Disable the SelfCompact rubric "
+ "probe that compacts the trajectory at a judged-safe point "
+ "(independent of the token thresholds above)\n"
+ " --rubric-max-summaries <N> Cap on rubric-triggered "
+ "compactions per session. The default is `1` (the paper's cap); "
+ "`0` means unlimited\n"
+ " --verbose Verbose, structured logging to stderr (one JSON "
+ "object per event: turn lifecycle, tool calls, compaction, "
+ "retries, and errors)\n"
+ " --debug Write the whole conversation history to "
+ "`debug.json` (in the working directory) after every turn and "
+ "again on exit\n"
+ " --skill-index-cap <SKILL_INDEX_CAP> Maximum number of "
+ "skills listed in the preamble's skills index\n"
+ " --shell-timeout-secs <SHELL_TIMEOUT_SECS> `run_shell` "
+ "timeout in seconds (the whole process tree is killed on expiry)\n"
+ " --max-output-chars <MAX_OUTPUT_CHARS> Cap on tool/shell "
+ "output text, in characters (longer is truncated)\n"
+ " --max-file-bytes <MAX_FILE_BYTES> Cap on the size of a "
+ "file `read_file` will read, in bytes\n"
+ " --resume <PATH> Resume a session saved by `/save`: restore "
+ "its conversation, model, preset, sub-agent, and working "
+ "directory. The saved model/config values are authoritative, so "
+ "`--model` / `--provider` / `--base-url` / `--api-key` are "
+ "ignored when this is set\n"
+ " --allow-mcp Trust and start MCP servers from .agents/mcp.json\n"
+ " --shell-allow <COMMAND> Allow this run_shell command (repeatable)\n"
+ " --shell-deny <COMMAND> Deny this run_shell command (repeatable)\n"
+ " --confirm-destructive Prompt before recognized destructive shell commands\n"
+ " --no-confirm-destructive Disable the destructive-command prompt\n"
+ " -h, --help Print help\n"
+ " -V, --version Print version\n",
+ LLSH_VERSION);
+}
+
+static int parse_ulong(const char *s, unsigned long *out, char **err,
+ const char *flag) {
+ /* strtoull silently accepts a leading '-' and wraps on overflow; reject
+ * both explicitly so `--max-turns -1` does not become ULONG_MAX. */
+ errno = 0;
+ char *endp = NULL;
+ unsigned long v = strtoull(s, &endp, 10);
+ if (s[0] == '-' || endp == s || *endp != '\0' || errno == ERANGE) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "invalid value for %s: `%s`", flag, s);
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ *out = v;
+ return 0;
+}
+
+static int parse_fraction_arg(const char *s, double *out, char **err,
+ const char *flag) {
+ errno = 0;
+ char *endp = NULL;
+ double value = strtod(s, &endp);
+ if (endp == s || *endp != '\0' || errno == ERANGE || !isfinite(value) ||
+ value < 0.0 || value > 1.0) {
+ if (err)
+ *err = xstrfmt("invalid value for %s: `%s` (expected 0 or a finite value in (0, 1])",
+ flag, s);
+ return -1;
+ }
+ *out = value;
+ return 0;
+}
+
+int cli_parse(int argc, char **argv, cli *out, char **err) {
+ memset(out, 0, sizeof *out);
+ sl_init(&out->shell_allowlist);
+ sl_init(&out->shell_denylist);
+
+ enum {
+ OPT_MODEL = 256,
+ OPT_PROVIDER,
+ OPT_BASE_URL,
+ OPT_API_KEY,
+ OPT_CWD,
+ OPT_PREAMBLE,
+ OPT_MAX_TURNS,
+ OPT_MAX_RETRIES,
+ OPT_CONTEXT_LIMIT,
+ OPT_COMPACT_THRESHOLD,
+ OPT_CLEAR_THRESHOLD,
+ OPT_VERBOSE,
+ OPT_DEBUG,
+ OPT_NO_RUBRIC,
+ OPT_RUBRIC_MAX,
+ OPT_SKILL_INDEX_CAP,
+ OPT_SHELL_TIMEOUT,
+ OPT_MAX_OUTPUT,
+ OPT_MAX_FILE,
+ OPT_RESUME,
+ OPT_ALLOW_MCP,
+ OPT_SHELL_ALLOW,
+ OPT_SHELL_DENY,
+ OPT_CONFIRM_DESTRUCTIVE,
+ OPT_NO_CONFIRM_DESTRUCTIVE,
+ };
+ static const struct option long_opts[] = {
+ {"model", required_argument, 0, OPT_MODEL},
+ {"provider", required_argument, 0, OPT_PROVIDER},
+ {"base-url", required_argument, 0, OPT_BASE_URL},
+ {"api-key", required_argument, 0, OPT_API_KEY},
+ {"cwd", required_argument, 0, OPT_CWD},
+ {"preamble", required_argument, 0, OPT_PREAMBLE},
+ {"max-turns", required_argument, 0, OPT_MAX_TURNS},
+ {"max-retries", required_argument, 0, OPT_MAX_RETRIES},
+ {"context-limit", required_argument, 0, OPT_CONTEXT_LIMIT},
+ {"compact-threshold", required_argument, 0, OPT_COMPACT_THRESHOLD},
+ {"clear-threshold", required_argument, 0, OPT_CLEAR_THRESHOLD},
+ {"verbose", no_argument, 0, OPT_VERBOSE},
+ {"debug", no_argument, 0, OPT_DEBUG},
+ {"no-rubric-compaction", no_argument, 0, OPT_NO_RUBRIC},
+ {"rubric-max-summaries", required_argument, 0, OPT_RUBRIC_MAX},
+ {"skill-index-cap", required_argument, 0, OPT_SKILL_INDEX_CAP},
+ {"shell-timeout-secs", required_argument, 0, OPT_SHELL_TIMEOUT},
+ {"max-output-chars", required_argument, 0, OPT_MAX_OUTPUT},
+ {"max-file-bytes", required_argument, 0, OPT_MAX_FILE},
+ {"resume", required_argument, 0, OPT_RESUME},
+ {"allow-mcp", no_argument, 0, OPT_ALLOW_MCP},
+ {"shell-allow", required_argument, 0, OPT_SHELL_ALLOW},
+ {"shell-deny", required_argument, 0, OPT_SHELL_DENY},
+ {"confirm-destructive", no_argument, 0, OPT_CONFIRM_DESTRUCTIVE},
+ {"no-confirm-destructive", no_argument, 0, OPT_NO_CONFIRM_DESTRUCTIVE},
+ {"help", no_argument, 0, 'h'},
+ {"version", no_argument, 0, 'V'},
+ {0, 0, 0, 0},
+ };
+
+ int c;
+ int prompt_set = 0;
+ optind = 1;
+ while ((c = getopt_long(argc, argv, "C:hV", long_opts, NULL)) != -1) {
+ switch (c) {
+ case OPT_MODEL:
+ out->model = xstrdup(optarg);
+ break;
+ case OPT_PROVIDER:
+ out->provider = xstrdup(optarg);
+ break;
+ case OPT_BASE_URL:
+ out->base_url = xstrdup(optarg);
+ break;
+ case OPT_API_KEY:
+ out->api_key = xstrdup(optarg);
+ break;
+ case OPT_CWD:
+ case 'C':
+ out->cwd = xstrdup(optarg);
+ break;
+ case OPT_PREAMBLE:
+ out->preamble = xstrdup(optarg);
+ break;
+ case OPT_MAX_TURNS:
+ if (parse_ulong(optarg, &out->max_turns, err, "--max-turns") != 0)
+ return -1;
+ out->has_max_turns = true;
+ break;
+ case OPT_MAX_RETRIES:
+ if (parse_ulong(optarg, &out->max_retries, err,
+ "--max-retries") != 0)
+ return -1;
+ out->has_max_retries = true;
+ break;
+ case OPT_CONTEXT_LIMIT:
+ if (parse_ulong(optarg, &out->context_limit, err,
+ "--context-limit") != 0)
+ return -1;
+ out->has_context_limit = true;
+ break;
+ case OPT_COMPACT_THRESHOLD:
+ if (parse_fraction_arg(optarg, &out->compact_threshold, err,
+ "--compact-threshold") != 0)
+ return -1;
+ out->has_compact_threshold = true;
+ break;
+ case OPT_CLEAR_THRESHOLD:
+ if (parse_fraction_arg(optarg, &out->clear_threshold, err,
+ "--clear-threshold") != 0)
+ return -1;
+ out->has_clear_threshold = true;
+ break;
+ case OPT_VERBOSE:
+ out->verbose = true;
+ break;
+ case OPT_DEBUG:
+ out->debug = true;
+ break;
+ case OPT_NO_RUBRIC:
+ out->no_rubric = true;
+ break;
+ case OPT_RUBRIC_MAX:
+ if (parse_ulong(optarg, &out->rubric_max_summaries, err,
+ "--rubric-max-summaries") != 0)
+ return -1;
+ out->has_rubric_max_summaries = true;
+ break;
+ case OPT_SKILL_INDEX_CAP:
+ if (parse_ulong(optarg, &out->skill_index_cap, err,
+ "--skill-index-cap") != 0)
+ return -1;
+ out->has_skill_index_cap = true;
+ break;
+ case OPT_SHELL_TIMEOUT:
+ if (parse_ulong(optarg, &out->shell_timeout_secs, err,
+ "--shell-timeout-secs") != 0)
+ return -1;
+ out->has_shell_timeout_secs = true;
+ break;
+ case OPT_MAX_OUTPUT:
+ if (parse_ulong(optarg, &out->max_output_chars, err,
+ "--max-output-chars") != 0)
+ return -1;
+ out->has_max_output_chars = true;
+ break;
+ case OPT_MAX_FILE:
+ if (parse_ulong(optarg, &out->max_file_bytes, err,
+ "--max-file-bytes") != 0)
+ return -1;
+ out->has_max_file_bytes = true;
+ break;
+ case OPT_RESUME:
+ out->resume = xstrdup(optarg);
+ break;
+ case OPT_ALLOW_MCP:
+ out->allow_mcp = true;
+ break;
+ case OPT_SHELL_ALLOW:
+ if (!optarg[0]) {
+ if (err)
+ *err = xstrdup("--shell-allow requires a non-empty command");
+ return -1;
+ }
+ sl_push_copy(&out->shell_allowlist, optarg);
+ break;
+ case OPT_SHELL_DENY:
+ if (!optarg[0]) {
+ if (err)
+ *err = xstrdup("--shell-deny requires a non-empty command");
+ return -1;
+ }
+ sl_push_copy(&out->shell_denylist, optarg);
+ break;
+ case OPT_CONFIRM_DESTRUCTIVE:
+ out->confirm_destructive = true;
+ out->has_confirm_destructive = true;
+ break;
+ case OPT_NO_CONFIRM_DESTRUCTIVE:
+ out->confirm_destructive = false;
+ out->has_confirm_destructive = true;
+ break;
+ case 'h':
+ print_usage(stdout);
+ return CLI_HELP;
+ case 'V':
+ fprintf(stdout, "llsh %s\n", LLSH_VERSION);
+ return CLI_HELP;
+ case '?':
+ print_usage(stderr);
+ if (err)
+ *err = xstrdup("try --help");
+ return -1;
+ default:
+ /* getopt_long returns '?' for unknown options; anything else
+ * here is a short option we did not register. */
+ print_usage(stderr);
+ if (err)
+ *err = xstrdup("try --help");
+ return -1;
+ }
+ }
+ for (int i = optind; i < argc; i++) {
+ if (prompt_set) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "unexpected argument `%s`", argv[i]);
+ char *r = xbuf_steal(&b);
+ if (err)
+ *err = r;
+ else
+ free(r);
+ return -1;
+ }
+ out->prompt = xstrdup(argv[i]);
+ prompt_set = 1;
+ }
+ return 0;
+}
+
+void cli_free(cli *c) {
+ free(c->prompt);
+ free(c->model);
+ free(c->provider);
+ free(c->base_url);
+ free(c->api_key);
+ free(c->cwd);
+ free(c->preamble);
+ free(c->resume);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Merge + validation */
+/* ------------------------------------------------------------------ */
+
+static int validate_fraction(const char *name, double value, char **err) {
+ if (value == 0.0)
+ return 0;
+ if (isnan(value) || isinf(value) || value <= 0.0 || value > 1.0) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ const char *rep;
+ if (isnan(value))
+ rep = "NaN";
+ else if (value > 0)
+ rep = "inf";
+ else
+ rep = "-inf";
+ xbuf_fmt(&b, "%s must be 0 or in (0, 1], got %s", name, rep);
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+static int validate_compaction(const config *c, char **err) {
+ if (validate_fraction("compact_threshold", c->compact_threshold, err) != 0)
+ return -1;
+ if (validate_fraction("clear_threshold", c->clear_threshold, err) != 0)
+ return -1;
+ if (c->context_limit > 0 && c->compact_threshold > 0.0 &&
+ c->clear_threshold > 0.0 &&
+ c->clear_threshold >= c->compact_threshold) {
+ if (err) {
+ char *ct = dtoa_dup(c->clear_threshold);
+ char *pt = dtoa_dup(c->compact_threshold);
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "clear_threshold (%s) must be less than "
+ "compact_threshold (%s)",
+ ct, pt);
+ *err = xbuf_steal(&b);
+ free(ct);
+ free(pt);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+int config_require_key_or_base(const config *c, char **err) {
+ if (c->provider != PROV_OLLAMA && !c->base_url && !c->api_key) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "no API key for provider `%s`: set `api_key` in "
+ "`.agents/models.json` or pass --api-key",
+ provider_debug(c->provider));
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+void config_free(config *c) {
+ free(c->model);
+ free(c->base_url);
+ free(c->api_key);
+ free(c->preamble);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+int config_merge(const cli *cli, config *out, char **err) {
+ memset(out, 0, sizeof *out);
+ sl_init(&out->shell_allowlist);
+ sl_init(&out->shell_denylist);
+ out->confirm_destructive = cli->confirm_destructive;
+ for (size_t i = 0; i < cli->shell_allowlist.len; i++)
+ sl_push_copy(&out->shell_allowlist, cli->shell_allowlist.items[i]);
+ for (size_t i = 0; i < cli->shell_denylist.len; i++)
+ sl_push_copy(&out->shell_denylist, cli->shell_denylist.items[i]);
+
+ out->provider = provider_parse(cli->provider ? cli->provider : "openai",
+ err);
+ if (err && *err)
+ return -1;
+
+ out->model = xstrdup(cli->model ? cli->model
+ : provider_default_model(out->provider));
+
+ out->base_url = cli->base_url ? xstrdup(cli->base_url) : NULL;
+ out->api_key = cli->api_key ? xstrdup(cli->api_key) : NULL;
+
+ out->preamble = cli->preamble ? xstrdup(cli->preamble)
+ : default_preamble();
+
+ out->max_turns = cli->has_max_turns ? cli->max_turns : 0;
+ out->max_retries = cli->has_max_retries ? cli->max_retries
+ : DEFAULT_MAX_RETRIES;
+ out->context_limit =
+ cli->has_context_limit ? cli->context_limit : 0;
+ out->compact_threshold = cli->has_compact_threshold ? cli->compact_threshold
+ : DEFAULT_COMPACT_THRESHOLD;
+ out->clear_threshold = cli->has_clear_threshold ? cli->clear_threshold
+ : DEFAULT_CLEAR_THRESHOLD;
+
+ out->rubric_enabled = !cli->no_rubric;
+ out->rubric_max_summaries = cli->has_rubric_max_summaries
+ ? cli->rubric_max_summaries
+ : RUBRIC_DEFAULT_MAX_SUMMARIES;
+
+ out->verbose = cli->verbose;
+
+ out->skill_index_cap =
+ cli->has_skill_index_cap ? cli->skill_index_cap
+ : DEFAULT_SKILL_INDEX_CAP;
+ if (out->skill_index_cap < 1)
+ out->skill_index_cap = 1;
+ out->shell_timeout_secs =
+ cli->has_shell_timeout_secs ? cli->shell_timeout_secs
+ : DEFAULT_SHELL_TIMEOUT_SECS;
+ if (out->shell_timeout_secs < 1)
+ out->shell_timeout_secs = 1;
+ out->max_output_chars =
+ cli->has_max_output_chars ? cli->max_output_chars
+ : DEFAULT_MAX_OUTPUT_CHARS;
+ if (out->max_output_chars < 1)
+ out->max_output_chars = 1;
+ out->max_file_bytes =
+ cli->has_max_file_bytes ? cli->max_file_bytes
+ : DEFAULT_MAX_FILE_BYTES;
+ if (out->max_file_bytes < 1)
+ out->max_file_bytes = 1;
+
+ if (validate_compaction(out, err) != 0)
+ return -1;
+ /* The api_key/base_url requirement is checked by the caller AFTER a
+ * models.json preset may have supplied them (precedence: CLI flag >
+ * models.json preset > provider defaults). */
+ return 0;
+}
+
+int config_from_cli(const cli *cli, config *out, char **err) {
+ const char *cwd = cli->cwd ? cli->cwd : ".";
+
+ if (config_merge(cli, out, err) != 0) {
+ config_free(out);
+ return -1;
+ }
+
+ char *cwd_exp = expand_tilde(cwd);
+ if (chdir(cwd_exp) != 0) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "failed to enter working directory `%s`: %s",
+ cwd_exp, strerror(errno));
+ *err = xbuf_steal(&b);
+ }
+ free(cwd_exp);
+ config_free(out);
+ return -1;
+ }
+ free(cwd_exp);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* models.json presets */
+/* ------------------------------------------------------------------ */
+
+char *config_apply_models_preset(config *cfg, const models_file *models,
+ const cli *cli, char **err) {
+ const char *name = models->default_name;
+ if (!name || !*name) {
+ if (err)
+ *err = xstrdup("models.json declares no `default` preset");
+ return NULL;
+ }
+ const model_preset *preset = models_file_get(models, name);
+ if (!preset) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, "models.json default preset `");
+ xbuf_put(&b, name);
+ xbuf_put(&b, "` is not in presets (");
+ for (size_t i = 0; i < models->preset_count; i++) {
+ if (i)
+ xbuf_put(&b, ", ");
+ xbuf_put(&b, models->presets[i].name ? models->presets[i].name : "");
+ }
+ xbuf_put(&b, ")");
+ *err = xbuf_steal(&b);
+ }
+ return NULL;
+ }
+ if (!cli->provider && preset->provider) {
+ cfg->provider = provider_parse(preset->provider, err);
+ if (err && *err)
+ return NULL;
+ }
+ if (!cli->model && preset->model) {
+ free(cfg->model);
+ cfg->model = xstrdup(preset->model);
+ }
+ if (!cli->base_url && preset->base_url) {
+ free(cfg->base_url);
+ cfg->base_url = xstrdup(preset->base_url);
+ }
+ if (!cli->api_key && preset->api_key) {
+ free(cfg->api_key);
+ cfg->api_key = xstrdup(preset->api_key);
+ }
+ if (!cli->has_max_turns && preset->has_max_turns &&
+ preset->max_turns > 0)
+ cfg->max_turns = preset->max_turns;
+ if (!cli->has_context_limit && preset->has_context_limit)
+ cfg->context_limit = preset->context_limit;
+
+ if (cfg->provider != PROV_OLLAMA && !cfg->base_url && !cfg->api_key) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "no API key for provider `%s` after applying preset "
+ "`%s`: set `api_key` in the preset/config file or "
+ "pass --api-key",
+ provider_debug(cfg->provider), name);
+ *err = xbuf_steal(&b);
+ }
+ return NULL;
+ }
+ if (validate_compaction(cfg, err) != 0)
+ return NULL;
+ return xstrdup(name);
+}