Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/cli/commands/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { registerSetupContinue } from "./setup-continue.mjs";
import { registerSetupCursor } from "./setup-cursor.mjs";
import { registerSetupRoo } from "./setup-roo.mjs";
import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs";
import { registerConnect } from "./connect.mjs";
import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs";
Expand Down Expand Up @@ -142,6 +143,7 @@ export function registerCommands(program) {
registerSetupCursor(program);
registerSetupRoo(program);
registerSetupCrush(program);
registerSetupGoose(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);
Expand Down
150 changes: 150 additions & 0 deletions bin/cli/commands/setup-goose.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* omniroute setup-goose — configure Goose (block/goose) for OmniRoute.
*
* Goose is a terminal AI agent with a file-based config at
* ~/.config/goose/config.yaml and env-var overrides. For a custom OpenAI-
* compatible endpoint it uses provider `openai` with OPENAI_HOST = the ROOT url
* (NO /v1 — Goose appends the path itself). This writes the config.yaml keys and
* prints the guaranteed env-var recipe (the API key lives in the OS keyring / env,
* never the config file).
*/

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_HOST (ROOT, no /v1 — Goose appends) + apiKey. */
export function resolveGooseTarget(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 { host: root, apiKey };
}

/** Merge Goose's provider/model keys into config.yaml object (preserve the rest). */
export function buildGooseConfig(existing, { host, model }) {
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
cfg.GOOSE_PROVIDER = "openai";
cfg.GOOSE_MODEL = model;
cfg.OPENAI_HOST = host; // ROOT — Goose appends /v1/chat/completions
return cfg;
}

/** The guaranteed env-var recipe (pure → testable). */
export function buildGooseEnvRecipe({ host, model }) {
return [
"export GOOSE_PROVIDER=openai",
`export OPENAI_HOST=${host}`,
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
`export GOOSE_MODEL=${model}`,
].join("\n");
}

function readYamlSafe(yaml, path) {
try {
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
} catch {
/* corrupt/missing */
}
return {};
}

async function fetchModelIds(host, apiKey) {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${host}/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 runSetupGooseCommand(opts = {}) {
const { host, apiKey } = resolveGooseTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");

printHeading("OmniRoute → Goose (openai-compatible)");
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);

let model = opts.model;
if (!model) {
const ids = await fetchModelIds(host, 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 Goose");
} finally {
prompt.close();
}
}
}
Comment on lines +98 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the server is offline or returns no models, fetchModelIds will return an empty array. In interactive mode (when --yes is not passed), the current implementation will skip prompting the user entirely and immediately fail with 'A model is required'. We should prompt the user for a model ID even if the automatic fetch returns no examples, allowing them to manually enter the model they wish to use.

Suggested change
let model = opts.model;
if (!model) {
const ids = await fetchModelIds(host, 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 Goose");
} finally {
prompt.close();
}
}
}
let model = opts.model;
if (!model && !opts.yes) {
const ids = await fetchModelIds(host, apiKey);
if (ids.length) {
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
}
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Goose");
} finally {
prompt.close();
}
}

if (!model) {
printError("A model is required. Pass --model <id>.");
return 2;
}

const yaml = await import("js-yaml");
const merged = buildGooseConfig(readYamlSafe(yaml, configPath), { host, 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}`);
}
Comment on lines +123 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Filesystem write operations (mkdirSync and writeFileSync) can fail due to permission issues, read-only filesystems, or disk space limits. Wrapping these calls in a try/catch block ensures the CLI handles these errors gracefully and prints a user-friendly error message instead of crashing with an unhandled exception.

Suggested change
} else {
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
}
} else {
try {
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
} catch (err) {
printError(`Failed to write config to ${configPath}: ${err.message}`);
return 1;
}
}


printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
console.log(buildGooseEnvRecipe({ host, model }));
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
return 0;
}

export function registerSetupGoose(program) {
program
.command("setup-goose")
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + 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 for Goose (required unless picked interactively)")
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
.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 runSetupGooseCommand(opts);
if (code !== 0) process.exit(code);
});
}
1 change: 1 addition & 0 deletions docs/guides/REMOTE-MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ context, or `--remote <url> --api-key <key>`):
| Cursor | `omniroute setup-cursor` | prints the in-app steps (Settings → Models → Override OpenAI Base URL **with** `/v1` + key + model). Cursor config is opaque SQLite — chat panel only |
| Roo Code | `omniroute setup-roo` | writes a Roo import JSON (`~/.omniroute/roo-settings.json`) + sets `roo-cline.autoImportSettingsPath` + prints UI steps (OpenAI-compatible, Base URL **with** `/v1`) |
| 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 |

```bash
# OpenCode (openai-compatible provider, all catalog models, remote VPS)
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/cli/setup-goose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveGooseTarget, buildGooseConfig, buildGooseEnvRecipe } from "../../../bin/cli/commands/setup-goose.mjs";

test("resolveGooseTarget strips /v1 (Goose appends it)", () => {
assert.equal(resolveGooseTarget({ remote: "http://vps:20128/v1/" }).host, "http://vps:20128");
assert.equal(resolveGooseTarget({ remote: "http://vps:20128" }).host, "http://vps:20128");
});
test("resolveGooseTarget: explicit --api-key wins", () => {
assert.equal(resolveGooseTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x");
});
test("buildGooseConfig sets GOOSE_PROVIDER/MODEL + OPENAI_HOST (root), preserves rest", () => {
const c = buildGooseConfig({ GOOSE_MODE: "auto" }, { host: "http://vps:20128", model: "glm/glm-5.2" });
assert.equal(c.GOOSE_PROVIDER, "openai");
assert.equal(c.GOOSE_MODEL, "glm/glm-5.2");
assert.equal(c.OPENAI_HOST, "http://vps:20128");
assert.equal(c.GOOSE_MODE, "auto");
});
test("buildGooseEnvRecipe references the env key (secret off disk)", () => {
const r = buildGooseEnvRecipe({ host: "http://vps:20128", model: "m" });
assert.ok(r.includes("OPENAI_HOST=http://vps:20128"));
assert.ok(r.includes("OPENAI_API_KEY=$OMNIROUTE_API_KEY"));
assert.ok(r.includes("GOOSE_PROVIDER=openai"));
});
Loading