From 1bd4b021100cfd1d3915445680449d5bf71fd127 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:57:06 -0300 Subject: [PATCH] feat(cli-tools): add CodeWhale CLI tool (#5996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeWhale (https://github.com/Hmbown/CodeWhale) is the actively-maintained successor to DeepSeek TUI β€” same author, renamed project. Added as a dual entry alongside the existing "deepseek-tui" catalog entry (rather than a hard rename) so users who still run the old DeepSeek TUI binary keep a working dashboard card, while new users are steered to "codewhale". New /api/cli-tools/codewhale-settings route writes the primary ~/.codewhale/config.toml and keeps an existing legacy ~/.deepseek/config.toml in sync (read fallback + best-effort write sync), mirroring deepseek-tui-settings/route.ts. CLI_TOOLS and cliRuntime catalogs updated; catalog cardinality tests/constants bumped accordingly (18β†’19 visible code tools, 28β†’29 total). Inspired-by: https://github.com/decolua/9router/pull/1761 Co-authored-by: aristorinjuang --- CHANGELOG.md | 1 + docs/reference/CLI-TOOLS.md | 8 +- .../api/cli-tools/codewhale-settings/route.ts | 235 +++++++++++++++ src/shared/constants/cliTools.ts | 31 +- src/shared/schemas/cliCatalog.ts | 4 +- src/shared/services/cliRuntime.ts | 9 + .../cli-settings-codewhale.test.ts | 279 ++++++++++++++++++ tests/unit/cli-catalog-acpspawnable.test.ts | 1 + tests/unit/cli-catalog-counts.test.ts | 11 +- tests/unit/cli-tools-schema.test.ts | 5 +- 10 files changed, 573 insertions(+), 11 deletions(-) create mode 100644 src/app/api/cli-tools/codewhale-settings/route.ts create mode 100644 tests/integration/cli-settings-codewhale.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6f41d231..83b8145f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - **feat(providers):** add Charm Hyper as an OpenAI-compatible (API-key) provider. (thanks @whale9820) - **feat(providers):** add SumoPod and X5Lab as OpenAI-compatible (API-key) providers. (thanks @rigelra15) - **feat(server):** support reverse-proxy subpath deployment via OMNIROUTE_BASE_PATH (basePath-aware auth redirects). (thanks @SillyHippy) +- **feat(cli-tools):** add CodeWhale CLI tool (successor to DeepSeek TUI). (thanks @aristorinjuang) ### πŸ”§ Bug Fixes diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 15d1162af1..42529429d2 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -12,7 +12,7 @@ OmniRoute integrates with three categories of CLI tools spread across three dedi | Page | Route | Concept | Count | | -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ | -| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client β†’ CLI β†’ OmniRoute β†’ Provider) | 19 | +| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client β†’ CLI β†’ OmniRoute β†’ Provider) | 20 | | **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 6 | | **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry | @@ -90,7 +90,7 @@ Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages --- -## 1. CLI Code's Catalog (19 tools) +## 1. CLI Code's Catalog (20 tools) Tools that support custom base URL and appear in `/dashboard/cli-code`: @@ -107,6 +107,7 @@ Tools that support custom base URL and appear in `/dashboard/cli-code`: | forge | ForgeCode | Antinomy HQ | full | custom | true | | jcode | jcode | 1jehuang (OSS) | full | custom | false | | deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false | +| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false | | opencode | OpenCode | Anomaly (ex-SST) | full | guide | true | | droid | Factory Droid | Factory AI | partial | guide | false | | copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | @@ -198,7 +199,8 @@ New tools with `configType: "custom"` have dedicated settings API routes: | ------------------------------------------- | ------------------------------ | | `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) | | `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) | -| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL) | +| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) | +| `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) | | `POST /api/cli-tools/smelt-settings` | Smelt | | `POST /api/cli-tools/pi-settings` | Pi coding agent | diff --git a/src/app/api/cli-tools/codewhale-settings/route.ts b/src/app/api/cli-tools/codewhale-settings/route.ts new file mode 100644 index 0000000000..273c1029ed --- /dev/null +++ b/src/app/api/cli-tools/codewhale-settings/route.ts @@ -0,0 +1,235 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; +import { cliModelConfigSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +const TOOL_ID = "codewhale"; + +/** + * CodeWhale is the actively-maintained successor to DeepSeek TUI (same + * author, renamed project β€” https://github.com/Hmbown/CodeWhale). It reads + * its config from ~/.codewhale/config.toml. Users upgrading from the old + * DeepSeek TUI binary may still have ~/.deepseek/config.toml around, so we + * read/write that path as a legacy fallback. + */ +const getPrimaryConfigPath = (): string => + getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".codewhale", "config.toml"); + +const getLegacyConfigPath = (): string => + path.join(process.env.HOME ?? "~", ".deepseek", "config.toml"); + +const getPrimaryConfigDir = () => path.dirname(getPrimaryConfigPath()); + +/** + * Render the OmniRoute config block in CodeWhale TOML format. + * CodeWhale reads OPENAI_BASE_URL and OPENAI_API_KEY from its config. + * Reference: https://github.com/Hmbown/CodeWhale + */ +function renderCodewhaleConfig(baseUrl: string, apiKey: string, model: string): string { + return [ + "# CodeWhale config β€” managed by OmniRoute (plan 14)", + "", + "[openai]", + `base_url = "${baseUrl}"`, + `api_key = "${apiKey}"`, + `model = "${model}"`, + "", + ].join("\n"); +} + +/** + * Check if the config file contains OmniRoute settings. + */ +const hasOmniRouteConfig = (content: string | null): boolean => { + if (!content) return false; + return content.includes("managed by OmniRoute"); +}; + +// Read current config.toml β€” prefers the primary ~/.codewhale path, falling +// back to the legacy ~/.deepseek path for users upgrading from DeepSeek TUI. +const readConfig = async (): Promise => { + for (const candidate of [getPrimaryConfigPath(), getLegacyConfigPath()]) { + try { + return await fs.readFile(candidate, "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + } + return null; +}; + +// GET β€” check CodeWhale CLI and return current config +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const runtime = await getCliRuntimeStatus(TOOL_ID); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: null, + message: + runtime.installed && !runtime.runnable + ? "CodeWhale is installed but not runnable" + : "CodeWhale is not installed", + }); + } + + const config = await readConfig(); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config, + hasOmniRoute: hasOmniRouteConfig(config), + configPath: getPrimaryConfigPath(), + }); + } catch (err) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(err) } }, + { status: 500 } + ); + } +} + +// POST β€” write OmniRoute settings to CodeWhale's config.toml (primary), and +// keep the legacy ~/.deepseek/config.toml in sync when it already exists so +// users who have not yet upgraded their CLI binary keep working. +export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body" } }, + { status: 400 } + ); + } + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + // Extract keyId BEFORE Zod validation β€” Zod strips unknown fields + const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; + + const validation = validateBody(cliModelConfigSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { baseUrl, model } = validation.data; + const apiKey = await resolveApiKey(keyId, validation.data.apiKey); + + const primaryPath = getPrimaryConfigPath(); + const legacyPath = getLegacyConfigPath(); + const content = renderCodewhaleConfig(baseUrl, apiKey, model); + + // Always write the primary (~/.codewhale) config. + await fs.mkdir(getPrimaryConfigDir(), { recursive: true }); + await createBackup(TOOL_ID, primaryPath); + await fs.writeFile(primaryPath, content, "utf-8"); + + // Best-effort: keep the legacy (~/.deepseek) config in sync only if it + // already exists β€” never create a fresh legacy directory for new users. + try { + await fs.access(legacyPath); + await createBackup(TOOL_ID, legacyPath); + await fs.writeFile(legacyPath, content, "utf-8"); + } catch { + /* legacy config not present β€” nothing to sync */ + } + + // Persist last-configured timestamp + try { + saveCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "CodeWhale settings applied successfully!", + configPath: primaryPath, + }); + } catch (err) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(err) } }, + { status: 500 } + ); + } +} + +// DELETE β€” remove OmniRoute CodeWhale config (primary + legacy, if present) +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const primaryPath = getPrimaryConfigPath(); + const legacyPath = getLegacyConfigPath(); + + // Backup + remove primary before removing + await createBackup(TOOL_ID, primaryPath); + await fs.rm(primaryPath, { force: true }); + + // Best-effort: remove legacy config too, if present + try { + await fs.access(legacyPath); + await createBackup(TOOL_ID, legacyPath); + await fs.rm(legacyPath, { force: true }); + } catch { + /* legacy config not present */ + } + + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured(TOOL_ID); + } catch { + /* non-critical */ + } + + return NextResponse.json({ + success: true, + message: "CodeWhale settings removed successfully", + }); + } catch (err) { + return NextResponse.json( + { error: { message: sanitizeErrorMessage(err) } }, + { status: 500 } + ); + } +} diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index b3eab82cf7..70245a675b 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -645,7 +645,13 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`, defaultCommand: "jcode", }, - /** β˜… Added by plan 14 (CLI Pages Redesign) β€” 2026-05-27 */ + /** + * β˜… Added by plan 14 (CLI Pages Redesign) β€” 2026-05-27 + * Kept as a legacy/dual entry after CodeWhale (see below) took over as the + * actively-maintained successor. Existing users who still have DeepSeek + * TUI installed keep a working dashboard card; new users are steered to + * "codewhale" instead. + */ "deepseek-tui": { id: "deepseek-tui", name: "DeepSeek TUI", @@ -661,6 +667,29 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`, defaultCommand: "deepseek-tui", }, + /** + * β˜… Added 2026-07-02 (dual-entry, see deepseek-tui above). CodeWhale is + * the actively-maintained successor to DeepSeek TUI β€” same author, new + * name. Config lives under ~/.codewhale/config.toml; the settings route + * also keeps ~/.deepseek/config.toml (legacy) in sync for upgrading + * users. Reference: https://github.com/Hmbown/CodeWhale + */ + codewhale: { + id: "codewhale", + name: "CodeWhale", + icon: "terminal", + color: "#4F46E5", + description: + "CodeWhale β€” Rust-based coding agent CLI with OPENAI_BASE_URL support (successor to DeepSeek TUI)", + docsUrl: "https://github.com/Hmbown/CodeWhale", + configType: "custom", + category: "code", + vendor: "OSS (Hmbown)", + acpSpawnable: false, + baseUrlSupport: "full", + defaultCommand: "codewhale", + }, + /** β˜… Added by plan 14 (CLI Pages Redesign) β€” 2026-05-27 */ smelt: { id: "smelt", diff --git a/src/shared/schemas/cliCatalog.ts b/src/shared/schemas/cliCatalog.ts index 98efb73614..22ef529ec5 100644 --- a/src/shared/schemas/cliCatalog.ts +++ b/src/shared/schemas/cliCatalog.ts @@ -60,5 +60,7 @@ export type CliCatalogEntry = z.infer; export const CliCatalogSchema = z.record(CliCatalogEntrySchema); /** Cardinalidade obrigatΓ³ria (Plano Β§3.1/Β§3.2 + D15). +1 (crush, decolua/9router#1233). */ -export const EXPECTED_CODE_COUNT = 19; +// +1 (2026-07-02): "codewhale" added as a dual entry alongside "deepseek-tui" +// (CodeWhale is the actively-maintained successor to DeepSeek TUI). +export const EXPECTED_CODE_COUNT = 20; export const EXPECTED_AGENT_COUNT = 6; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index e3d31cd86b..d2a37a1f53 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -210,6 +210,15 @@ const CLI_TOOLS: Record = { config: ".config/deepseek-tui/config.toml", }, }, + codewhale: { + defaultCommand: "codewhale", + envBinKey: "CLI_CODEWHALE_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + config: ".codewhale/config.toml", + }, + }, smelt: { defaultCommand: "smelt", envBinKey: "CLI_SMELT_BIN", diff --git a/tests/integration/cli-settings-codewhale.test.ts b/tests/integration/cli-settings-codewhale.test.ts new file mode 100644 index 0000000000..8a98ff547b --- /dev/null +++ b/tests/integration/cli-settings-codewhale.test.ts @@ -0,0 +1,279 @@ +/** + * Integration tests for /api/cli-tools/codewhale-settings + * + * CodeWhale (https://github.com/Hmbown/CodeWhale) is the actively-maintained + * successor to DeepSeek TUI (same author, renamed project). This route mirrors + * deepseek-tui-settings/route.ts but writes/reads a dual config path: + * - primary: ~/.codewhale/config.toml + * - legacy: ~/.deepseek/config.toml (kept in sync when it already exists, + * so users upgrading their CLI binary keep working) + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codewhale-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-codewhale"; +process.env.JWT_SECRET = "test-jwt-secret-codewhale"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/codewhale-settings/route.ts"); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function enableAuth() { + process.env.INITIAL_PASSWORD = "test-bootstrap"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +// ── Test 1: GET without auth β†’ 401 ────────────────────────────────────────── + +test("codewhale-settings GET: returns 401 when auth required and no token", async () => { + await enableAuth(); + const res = await GET(new Request("http://localhost/api/cli-tools/codewhale-settings")); + assert.equal(res.status, 401, `Expected 401, got ${res.status}`); +}); + +// ── Test 2: GET without auth requirement β†’ 200 ─────────────────────────────── + +test("codewhale-settings GET: returns 200 when auth not required", async () => { + const res = await GET(new Request("http://localhost/api/cli-tools/codewhale-settings")); + assert.equal(res.status, 200, `Expected 200, got ${res.status}`); + const body = await res.json(); + assert.ok( + "installed" in body || "config" in body, + "Response should contain installed or config field" + ); +}); + +// ── Test 3: POST with invalid body β†’ 400 ───────────────────────────────────── + +test("codewhale-settings POST: 400 when baseUrl is missing", async () => { + const res = await POST( + new Request("http://localhost/api/cli-tools/codewhale-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "sk-test", model: "deepseek-v4-pro" }), + }) + ); + assert.equal(res.status, 400, `Expected 400, got ${res.status}`); + const body = await res.json(); + assert.ok(body.error !== undefined); +}); + +test("codewhale-settings POST: 400 when model is missing", async () => { + const res = await POST( + new Request("http://localhost/api/cli-tools/codewhale-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", apiKey: "sk-test" }), + }) + ); + assert.equal(res.status, 400, `Expected 400, got ${res.status}`); +}); + +// ── Test 4: POST with valid body β†’ writes PRIMARY config.toml only (no legacy dir) ── + +test("codewhale-settings POST: writes primary ~/.codewhale/config.toml for a fresh install", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-home-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + const res = await POST( + new Request("http://localhost/api/cli-tools/codewhale-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test-codewhale-key", + model: "deepseek-v4-pro", + }), + }) + ); + assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`); + if (res.status === 200) { + const body = await res.json(); + assert.equal(body.success, true); + + const primaryPath = path.join(tmpHome, ".codewhale", "config.toml"); + assert.ok(fs.existsSync(primaryPath), "Primary ~/.codewhale/config.toml must be written"); + const content = fs.readFileSync(primaryPath, "utf-8"); + assert.ok(content.includes("managed by OmniRoute"), "Config should have OmniRoute marker"); + assert.ok(content.includes("http://localhost:20128"), "Config should contain base URL"); + assert.ok(content.includes("[openai]"), "Config should have [openai] section"); + + // No legacy ~/.deepseek dir existed before the write β€” must NOT be created. + const legacyPath = path.join(tmpHome, ".deepseek", "config.toml"); + assert.ok( + !fs.existsSync(legacyPath), + "Legacy ~/.deepseek/config.toml must not be created for a fresh install" + ); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 5: POST keeps an EXISTING legacy ~/.deepseek config in sync ──────── + +test("codewhale-settings POST: syncs an existing legacy ~/.deepseek/config.toml", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-home-legacy-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + // Simulate an existing DeepSeek TUI install (pre-CodeWhale upgrade). + const legacyDir = path.join(tmpHome, ".deepseek"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "config.toml"), 'provider = "deepseek"\n'); + + const res = await POST( + new Request("http://localhost/api/cli-tools/codewhale-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test-codewhale-key", + model: "deepseek-v4-flash", + }), + }) + ); + assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`); + if (res.status === 200) { + const primaryPath = path.join(tmpHome, ".codewhale", "config.toml"); + const legacyPath = path.join(tmpHome, ".deepseek", "config.toml"); + + assert.ok(fs.existsSync(primaryPath), "Primary config must be written"); + assert.ok(fs.existsSync(legacyPath), "Legacy config must still exist"); + + const primaryContent = fs.readFileSync(primaryPath, "utf-8"); + const legacyContent = fs.readFileSync(legacyPath, "utf-8"); + assert.ok(primaryContent.includes("http://localhost:20128")); + assert.ok( + legacyContent.includes("http://localhost:20128"), + "Legacy config must be kept in sync with the new base URL" + ); + assert.equal(primaryContent, legacyContent, "Primary and legacy configs should match"); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 6: GET reads from legacy path when only legacy config exists ─────── + +test("codewhale-settings GET: falls back to legacy ~/.deepseek/config.toml when primary is absent", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-home-getlegacy-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + const legacyDir = path.join(tmpHome, ".deepseek"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync( + path.join(legacyDir, "config.toml"), + '# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n' + ); + + const res = await GET(new Request("http://localhost/api/cli-tools/codewhale-settings")); + assert.equal(res.status, 200); + const body = await res.json(); + if (body.config) { + assert.ok(body.config.includes("managed by OmniRoute")); + assert.equal(body.hasOmniRoute, true); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 7: DELETE β†’ removes both primary and legacy config files ─────────── + +test("codewhale-settings DELETE: removes primary and legacy config files", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "codewhale-home-del-")); + const origHome = process.env.HOME; + process.env.HOME = tmpHome; + + try { + const primaryDir = path.join(tmpHome, ".codewhale"); + const legacyDir = path.join(tmpHome, ".deepseek"); + fs.mkdirSync(primaryDir, { recursive: true }); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync( + path.join(primaryDir, "config.toml"), + '# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n' + ); + fs.writeFileSync( + path.join(legacyDir, "config.toml"), + '# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n' + ); + + const res = await DELETE( + new Request("http://localhost/api/cli-tools/codewhale-settings", { method: "DELETE" }) + ); + assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`); + if (res.status === 200) { + const body = await res.json(); + assert.equal(body.success, true); + assert.ok(!fs.existsSync(path.join(primaryDir, "config.toml")), "Primary config removed"); + assert.ok(!fs.existsSync(path.join(legacyDir, "config.toml")), "Legacy config removed"); + } + } finally { + process.env.HOME = origHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +// ── Test 8: Error sanitization (Hard Rule #12) ─────────────────────────────── + +test("codewhale-settings: error responses do not leak stack traces", async () => { + const badReq = new Request("http://localhost/api/cli-tools/codewhale-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ bad json }", + }); + const res = await POST(badReq); + const bodyStr = JSON.stringify(await res.json()); + assert.ok( + !bodyStr.match(/\s+at\s+\/[^\s]/), + "Error response must not contain absolute-path stack traces" + ); +}); + +// ── Test 9: Hard Rule #13 (no exec/spawn) ──────────────────────────────────── + +test("codewhale-settings route.ts: does not call exec() or spawn() directly", () => { + const routePath = path.resolve( + import.meta.dirname, + "../../src/app/api/cli-tools/codewhale-settings/route.ts" + ); + const content = fs.readFileSync(routePath, "utf-8"); + assert.ok(!content.match(/\bexec\s*\(/), "Handler must not use exec()"); + assert.ok(!content.match(/\bspawn\s*\(/), "Handler must not use spawn()"); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.API_KEY_SECRET; + delete process.env.JWT_SECRET; +}); diff --git a/tests/unit/cli-catalog-acpspawnable.test.ts b/tests/unit/cli-catalog-acpspawnable.test.ts index d0b6654476..06e79f76ce 100644 --- a/tests/unit/cli-catalog-acpspawnable.test.ts +++ b/tests/unit/cli-catalog-acpspawnable.test.ts @@ -44,6 +44,7 @@ const NOT_ACP_SPAWNABLE_IDS = [ "roo", "jcode", "deepseek-tui", + "codewhale", "smelt", "pi", "hermes-agent", diff --git a/tests/unit/cli-catalog-counts.test.ts b/tests/unit/cli-catalog-counts.test.ts index 8b7c7d4ea6..e65516f228 100644 --- a/tests/unit/cli-catalog-counts.test.ts +++ b/tests/unit/cli-catalog-counts.test.ts @@ -30,7 +30,7 @@ test(`CLI_TOOLS has exactly ${EXPECTED_AGENT_COUNT} agent entries`, () => { ); }); -test("CLI_TOOLS total code entries (including none) equals 23 (19 visible + 4 none)", () => { +test("CLI_TOOLS total code entries (including none) equals 24 (20 visible + 4 none)", () => { // code-none entries: antigravity, kiro, cursor (app), hermes (simple guide) const codeNone = codeAll.filter((t) => t.baseUrlSupport === "none"); assert.equal( @@ -38,11 +38,11 @@ test("CLI_TOOLS total code entries (including none) equals 23 (19 visible + 4 no 4, `Expected 4 code entries with baseUrlSupport='none', got ${codeNone.length}: ${codeNone.map((t) => t.id).join(", ")}` ); - assert.equal(codeAll.length, 23, `Expected 23 total code entries, got ${codeAll.length}`); + assert.equal(codeAll.length, 24, `Expected 24 total code entries, got ${codeAll.length}`); }); -test("CLI_TOOLS total (code + agent) = 29", () => { - assert.equal(all.length, 29, `Expected 29 total entries, got ${all.length}`); +test("CLI_TOOLS total (code + agent) = 30", () => { + assert.equal(all.length, 30, `Expected 30 total entries, got ${all.length}`); }); test("All code-none entries have configType mitm OR are legacy excluded entries", () => { @@ -66,7 +66,7 @@ test("All agent entries have baseUrlSupport 'full' or 'partial' (no agent is 'no } }); -test("The 19 visible code entries match D15 list + crush exactly", () => { +test("The 20 visible code entries match D15 list exactly (+ crush + codewhale)", () => { const d15List = new Set([ "claude", "codex", @@ -79,6 +79,7 @@ test("The 19 visible code entries match D15 list + crush exactly", () => { "forge", "jcode", "deepseek-tui", + "codewhale", "opencode", "droid", "copilot", diff --git a/tests/unit/cli-tools-schema.test.ts b/tests/unit/cli-tools-schema.test.ts index 4437ff549b..b74e274eef 100644 --- a/tests/unit/cli-tools-schema.test.ts +++ b/tests/unit/cli-tools-schema.test.ts @@ -1,12 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -test("CLI_TOOLS registry contains all expected tools (plan 14 β€” 28 total + crush)", async () => { +test("CLI_TOOLS registry contains all expected tools (plan 14 β€” 30 total + crush + codewhale)", async () => { const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts"); // windsurf and amp removed per plan 14 D17 (MITM backlog plan 11) // New entries added: roo, jcode, deepseek-tui, smelt, pi, aider, forge, // cursor-cli, goose, interpreter, warp, agent-deck (+ hermes-agent already existed) // crush added β€” ported from upstream decolua/9router#1233 + // codewhale added 2026-07-02 as a dual entry alongside deepseek-tui + // (CodeWhale is the actively-maintained successor to DeepSeek TUI). const expected = [ "claude", "codex", @@ -30,6 +32,7 @@ test("CLI_TOOLS registry contains all expected tools (plan 14 β€” 28 total + cru "roo", "jcode", "deepseek-tui", + "codewhale", "smelt", "pi", "goose",