feat(cli): setup-aider — configure Aider for OmniRoute (.aider.conf.yml + env recipe) (#4302)

CLI #12 of the series. `omniroute setup-aider` writes Aider's ~/.aider.conf.yml
(openai-api-base = ROOT url, NO /v1 — LiteLLM appends /v1/chat/completions —
model: openai/<id>), merges to preserve existing config, and prints the env
recipe (OPENAI_API_BASE + OPENAI_API_KEY in the env, never the file) plus the
headless command (aider --message ... --yes). Remote-aware; model via --model or
interactive pick.

Researched against aider.chat: OpenAI-compatible via OPENAI_API_BASE (base, no
/v1) + --model openai/<id>. Aider's wire (/v1/chat/completions) already validated → "OK".

Tests: resolveAiderTarget (/v1 strip, key), buildAiderConfig (openai-api-base +
openai/<model> + preserve), buildAiderRecipe (env-ref key + headless). 4 unit tests; cli-i18n green.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 17:49:47 -03:00
committed by GitHub
parent b01b72052f
commit 165d9cdae9
4 changed files with 173 additions and 0 deletions

View File

@@ -68,6 +68,7 @@ import { registerSetupRoo } from "./setup-roo.mjs";
import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs";
import { registerSetupQwen } from "./setup-qwen.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerConnect } from "./connect.mjs";
import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs";
@@ -146,6 +147,7 @@ export function registerCommands(program) {
registerSetupCrush(program);
registerSetupGoose(program);
registerSetupQwen(program);
registerSetupAider(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);

View File

@@ -0,0 +1,146 @@
/**
* omniroute setup-aider — configure Aider (aider.chat) for OmniRoute.
*
* Aider (LiteLLM under the hood) talks to an OpenAI-compatible endpoint via env
* `OPENAI_API_BASE` (ROOT url — LiteLLM appends /v1/chat/completions) + the model
* flag `--model openai/<model>`. This writes ~/.aider.conf.yml (openai-api-base +
* model) — the key stays in OPENAI_API_KEY (env, never the file) — and prints the
* guaranteed env recipe + headless command. Remote-aware.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1") ? s.slice(0, -3) : s;
}
/** Resolve OPENAI_API_BASE (ROOT, no /v1 — LiteLLM appends) + apiKey. */
export function resolveAiderTarget(opts = {}) {
let root;
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* none */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { apiBase: root, apiKey };
}
/** Merge openai-api-base + model into an .aider.conf.yml object (preserve rest). */
export function buildAiderConfig(existing, { apiBase, model }) {
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
cfg["openai-api-base"] = apiBase;
if (model) cfg.model = `openai/${model}`;
return cfg;
}
/** The guaranteed env + run recipe (pure → testable). */
export function buildAiderRecipe({ apiBase, model }) {
return [
`export OPENAI_API_BASE=${apiBase}`,
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
`aider --model openai/${model}`,
`# headless: aider --model openai/${model} --message "reply OK" --yes`,
].join("\n");
}
function readYamlSafe(yaml, path) {
try {
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
} catch {
/* corrupt/missing */
}
return {};
}
async function fetchModelIds(apiBase, apiKey) {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
}
}
export async function runSetupAiderCommand(opts = {}) {
const { apiBase, apiKey } = resolveAiderTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
let model = opts.model;
if (!model) {
const ids = await fetchModelIds(apiBase, apiKey);
if (ids.length && !opts.yes) {
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Aider (without the openai/ prefix)");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
return 2;
}
const yaml = await import("js-yaml");
const merged = buildAiderConfig(readYamlSafe(yaml, configPath), { apiBase, model });
const out = yaml.dump(merged, { lineWidth: -1 });
if (dryRun) {
console.log("\n" + out);
printInfo(`[dry-run] → ${configPath}`);
} else {
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
}
printInfo("\nProvide the key + run (the key stays in the env, never the file):");
console.log(buildAiderRecipe({ apiBase, model }));
return 0;
}
export function registerSetupAider(program) {
program
.command("setup-aider")
.description("Configure Aider for OmniRoute: write ~/.aider.conf.yml + print the env recipe")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id (the openai/ prefix is added automatically)")
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const code = await runSetupAiderCommand(opts);
if (code !== 0) process.exit(code);
});
}

View File

@@ -155,6 +155,7 @@ context, or `--remote <url> --api-key <key>`):
| Crush | `omniroute setup-crush` | `~/.config/crush/crush.json``openai-compat` provider, `base_url` **with** `/v1`, key via `$OMNIROUTE_API_KEY` |
| Goose | `omniroute setup-goose` | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER=openai` + `OPENAI_HOST` **without** `/v1` + `GOOSE_MODEL`) + env recipe |
| Qwen Code | `omniroute setup-qwen` | `~/.qwen/settings.json` — openai `modelProvider`, `baseUrl` **with** `/v1`, key via `envKey` (OMNIROUTE_API_KEY) |
| Aider | `omniroute setup-aider` | `~/.aider.conf.yml` (`openai-api-base` **without** `/v1` + `model: openai/<id>`) + env recipe (`aider --message --yes`) |
```bash
# OpenCode (openai-compatible provider, all catalog models, remote VPS)

View File

@@ -0,0 +1,24 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveAiderTarget, buildAiderConfig, buildAiderRecipe } from "../../../bin/cli/commands/setup-aider.mjs";
test("resolveAiderTarget strips /v1 (LiteLLM appends it)", () => {
assert.equal(resolveAiderTarget({ remote: "http://vps:20128/v1/" }).apiBase, "http://vps:20128");
assert.equal(resolveAiderTarget({ remote: "http://vps:20128" }).apiBase, "http://vps:20128");
});
test("resolveAiderTarget: explicit --api-key wins", () => {
assert.equal(resolveAiderTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x");
});
test("buildAiderConfig sets openai-api-base + openai/<model>, preserves rest", () => {
const c = buildAiderConfig({ "auto-commits": false }, { apiBase: "http://vps:20128", model: "glm/glm-5.2" });
assert.equal(c["openai-api-base"], "http://vps:20128");
assert.equal(c.model, "openai/glm/glm-5.2");
assert.equal(c["auto-commits"], false);
});
test("buildAiderRecipe references the env key + headless command", () => {
const r = buildAiderRecipe({ apiBase: "http://vps:20128", model: "glm/glm-5.2" });
assert.ok(r.includes("OPENAI_API_BASE=http://vps:20128"));
assert.ok(r.includes("OPENAI_API_KEY=$OMNIROUTE_API_KEY"));
assert.ok(r.includes("--model openai/glm/glm-5.2"));
assert.ok(r.includes("--message") && r.includes("--yes"));
});