From 1ad5a1c15cb090bf6cbc64bc53b2b9ac38d53a3c Mon Sep 17 00:00:00 2001 From: Rousan Ali Date: Sun, 30 Aug 2026 13:28:20 +0530 Subject: [PATCH] Add a vanilla mode that serves a plain model instead of an agent ccal runs claude as a full Claude Code agent, which is right when the caller wants an agent. It is wrong when the caller is itself an agent: point opencode at ccal today and two tool loops run against each other, while a CLAUDE.md sitting in the working directory quietly joins in. --vanilla turns one server into a plain model endpoint. Tools off, no skills or plugins or hooks, no MCP servers, no CLAUDE.md, and the caller's system message replaces the agent prompt rather than being appended to it. The default is untouched, so existing callers see no change. Replacing the prompt rather than appending is what makes the rest work. With tools disabled but the agent framing still in place, the model believes it has tools and writes fake tool calls as prose. When a request carries no system message at all there is nothing to replace it with, so a plain assistant prompt stands in. --safe-mode rather than --bare, which is the flag this obviously wanted. --bare reads Anthropic auth strictly from ANTHROPIC_API_KEY and never touches OAuth or the keychain, so it breaks ccal for the subscription users it exists for. Verified: claude -p --bare on a logged-in machine with no API key answers "Not logged in". --safe-mode disables the same customizations and leaves auth alone. The model list drops "tools" from supported_parameters in this mode, since advertising a capability the server has just removed would be a lie a client is entitled to believe. "reasoning" stays, being a property of the model rather than of the agent. --- CHANGELOG.md | 10 +++++ CLAUDE.md | 49 +++++++++++++++++---- README.md | 51 ++++++++++++++++++++-- docs/architecture.md | 69 ++++++++++++++++++++++++++++- docs/development.md | 21 +++++++++ src/claude.ts | 102 ++++++++++++++++++++++++++++++++++++++----- src/cli.ts | 28 +++++++++++- src/models.ts | 18 +++++++- src/server.ts | 13 +++++- 9 files changed, 333 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3265b93..6829b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`--vanilla`.** Serves every request as a plain model call instead of a full + Claude Code agent: no built-in tools, no MCP servers, no `CLAUDE.md`, and the + caller's system message replaces the agent prompt instead of extending it. + For callers that run their own tool loop (opencode being the motivating + case) and would otherwise end up fighting a second agent underneath ccal. + Off by default; existing agentic behaviour is unchanged. `GET /v1/models` + drops `"tools"` from `supported_parameters` in this mode. + ## [1.4.0] - 2026-08-06 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 178aa2f..804e904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,12 @@ endpoint. | File | Responsibility | |---|---| -| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. | +| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--allow-origin`, `--vanilla`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. | | `server.ts` | Builds the Hono app. Mounts `GET /health`, `GET /v1/models`, and `POST /v1/chat/completions`; sets up permissive CORS; orchestrates prompt prep → spawn → stream translation for both the streaming (SSE) and non-streaming paths; reaps the child process. | | `prompt.ts` | Flattens an OpenAI `messages` array into a single stdin text payload plus a combined system prompt. System messages are joined; user/assistant turns become a labeled `User:` / `Assistant:` transcript (a lone message is sent verbatim). | -| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. | +| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic (or, in vanilla mode, plain-model) flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. | | `stream.ts` | Translates the CLI's `stream-json` events into user-visible text: text deltas pass through; tool calls are accumulated and rendered as a compact one-line italic note. Holds the per-response `StreamState`. | -| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). | +| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). In vanilla mode, drops `"tools"` from each model's `supported_parameters`. | | `claude-binary.ts` | Resolves the absolute path to the `claude` binary across several strategies (see below), and probes its `--version` at startup. | ## The `claude` CLI contract ccal depends on @@ -46,7 +46,8 @@ claude -p \ --verbose \ --model \ --no-session-persistence \ - [--append-system-prompt ] \ + [--append-system-prompt ] # agentic mode, system content only + [--tools "" --safe-mode --strict-mcp-config --system-prompt ] # vanilla mode only [--permission-mode ] ``` @@ -57,12 +58,44 @@ claude -p \ - `--model` takes the alias from the request (`sonnet` / `opus` / `haiku`). - `--no-session-persistence` keeps each request stateless. - `--append-system-prompt` is added only when the request has system content, so - the default agent prompt (and tool use) is preserved. + the default agent prompt (and tool use) is preserved. **Agentic mode only.** - `--permission-mode` is forwarded only for an explicit non-default value. -It runs as a **full Claude Code agent** — tools, MCP servers, and `CLAUDE.md` -load based on `--cwd`. `--bare`, `--strict-mcp-config`, and `--setting-sources` -are deliberately not passed. +It runs as a **full Claude Code agent by default** — tools, MCP servers, and +`CLAUDE.md` load based on `--cwd`. `--bare`, `--strict-mcp-config`, and +`--setting-sources` are deliberately not passed in this mode. + +**Vanilla mode** (`--vanilla` on `ccal serve`, `vanilla: true` in `SpawnOptions`) +runs a plain model call instead, for callers (opencode being the motivating one) +that run their own tool loop and would otherwise end up with two agents fighting +over the same turn. Four flags change, each verified against `claude --help` +rather than assumed — the reasoning lives in the comment on the vanilla block in +`spawnClaude()` (`claude.ts`), and is worth reading in full before touching any +of them: + +- **`--tools ""`** disables every built-in tool. On its own this does *not* stop + `CLAUDE.md` or MCP servers from loading. +- **`--safe-mode`, not `--bare`.** `--bare` looks like the obvious flag for + killing `CLAUDE.md` auto-discovery, but its own help text says Anthropic auth + becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper` — OAuth and keychain are + never read. Verified: `claude -p --bare` on a subscription-logged-in box fails + with "Not logged in - Please run /login", which would break ccal for exactly + the users it exists for. `--safe-mode` disables `CLAUDE.md`, skills, plugins, + hooks and MCP servers while explicitly leaving auth working normally. +- **`--strict-mcp-config`** is mostly redundant with what `--safe-mode` already + does (verified via the `stream-json` init event's `mcp_servers` field), but is + kept as a second, low-cost guarantee — `--safe-mode`'s own help text carves out + an exception for admin-managed settings, and it's unclear whether that could + include a managed MCP server. +- **`--system-prompt`, not `--append-system-prompt`.** Appending leaves the + agent's default system prompt in place, and even with zero tools available the + model still believes it's an agent with tools — verified: it emitted fake + `...` text instead of a plain answer. `--system-prompt` + replaces the prompt outright. When the request has no system message, ccal + substitutes a small neutral default rather than passing `--system-prompt ""` + or omitting the flag — both were tested and both fall back to the same broken + agent-framing behaviour (and a whitespace-only prompt is rejected outright by + the API). **Binary resolution order** (`claude-binary.ts`): `CCAL_CLAUDE_PATH` → scan `PATH` → ask the login shell (`$SHELL -lic 'command -v claude'`) → diff --git a/README.md b/README.md index 51f6aa2..159a60f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of - `GET /health` → `{ "ok": true }` - `GET /v1/models` → the OpenAI model list. Three ids are advertised, matching - the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`. + the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`. When the + server was started with `--vanilla`, `supported_parameters` drops `"tools"` + for every model, since the backing agent has none to use. - `POST /v1/chat/completions` → OpenAI-compatible chat completions. Supports both `stream: true` (SSE `data: {choices:[{delta:{content}}]}` frames terminated by `data: [DONE]`) and the non-streaming case (a single assembled @@ -116,7 +118,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of - `system` messages are combined and passed to claude via `--append-system-prompt` (appended to the default agent prompt, so tool use - still works). + still works). In [`--vanilla`](#vanilla-mode-no-tools-no-mcp-no-claudemd) + mode they go through `--system-prompt` instead, replacing the prompt rather + than extending it. - `user` / `assistant` messages are flattened into a single text transcript sent on stdin. A lone message is sent verbatim; a multi-turn conversation is rendered as a `User:` / `Assistant:` labeled transcript. This is one prompt in, @@ -142,14 +146,55 @@ ccal serve [options] --permission-mode Permission mode passed to claude (non-default only) --allow-origin Let a site on the public internet call this server (repeatable) + --vanilla Serve plain model calls instead of a full Claude + Code agent (no tools, no MCP servers, no CLAUDE.md) --help Show this help ``` - `--cwd` controls where claude runs, which determines the tools, MCP servers, - and `CLAUDE.md` it loads. + and `CLAUDE.md` it loads (agentic mode only — `--vanilla` turns all three off + regardless of `--cwd`). - `CCAL_CLAUDE_PATH` (environment variable) forces a specific `claude` binary when auto-detection does not find the right one. +### Vanilla mode: no tools, no MCP, no CLAUDE.md + +By default ccal runs `claude` as a **full Claude Code agent**: it has its +built-in tools, connects to whatever MCP servers your `claude` config defines, +and loads `CLAUDE.md` from `--cwd`. That is the right shape for most uses of +ccal — a chat UI or SDK that just wants the model to actually do things. + +It is the wrong shape when the *caller* is itself an agent with its own tool +loop — for example [opencode](https://opencode.ai). Point opencode at ccal +without `--vanilla` and you get two independent agents fighting over the same +turn: opencode issues tool calls of its own while the `claude` process +underneath is separately reading files, running bash, and picking up whatever +`CLAUDE.md` sits in `--cwd`. + +```sh +ccal serve --vanilla +``` + +turns every request into a plain model call: + +- No built-in tools (`--tools ""` on the `claude` invocation). +- No `CLAUDE.md`, skills, plugins, hooks, or MCP servers (`--safe-mode`, plus + `--strict-mcp-config` as a second guarantee against MCP). +- The caller's `system` message **replaces** the agent's default system prompt + (`--system-prompt`) instead of being appended to it — so the model isn't + told it's a coding agent with tools it no longer has. If a request carries + no system message, ccal substitutes a small neutral one rather than leaving + the agent framing in place. + +Your own subscription login still works — vanilla mode does *not* use +`--bare`, which would require `ANTHROPIC_API_KEY` and stop reading your +OAuth/keychain login. `GET /v1/models` also drops `"tools"` from +`supported_parameters` in this mode, since the backing model genuinely can't +use any. + +Off by default; existing consumers that rely on the agentic behaviour are +unaffected. + ### Calling ccal from a website If the page calling ccal is served over the public internet — a deployed app diff --git a/docs/architecture.md b/docs/architecture.md index f4f0df4..7e5609f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,65 @@ Notably, `--bare`, `--strict-mcp-config`, and `--setting-sources` are **not** passed. ccal runs claude as a full Claude Code agent: tools, MCP servers, and `CLAUDE.md` all load relative to `--cwd`. -Two practical details: +#### Vanilla mode + +When the server was started with `--vanilla` (`AdapterConfig.vanilla` → +`SpawnOptions.vanilla`), `spawnClaude()` builds a different invocation — a plain +model call instead of an agent: + +``` +claude -p \ + --output-format stream-json \ + --include-partial-messages \ + --verbose \ + --model \ + --no-session-persistence \ + --tools "" \ + --safe-mode \ + --strict-mcp-config \ + --system-prompt \ + [--permission-mode ] +``` + +- **`--tools ""`** disables every built-in tool (`""` is the CLI's own syntax + for "no tools"). By itself this does not stop `CLAUDE.md` or MCP servers from + loading — verified: with only `--tools ""`, a `CLAUDE.md` in `--cwd` still + leaked its content into the response. +- **`--safe-mode`** (not `--bare`) is what actually stops `CLAUDE.md` + auto-discovery, along with skills, plugins, and hooks, plus MCP servers. + `--bare` was the first instinct and is wrong here: its help text says + Anthropic auth becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper`, and + OAuth/keychain are never read. Verified by running `claude -p --bare` on a + subscription-logged-in machine: it failed with "Not logged in - Please run + /login" even though the identical prompt without `--bare` succeeded. That + would defeat ccal's whole reason for existing — driving your existing + subscription login, no API key. `--safe-mode` gets the CLAUDE.md/MCP + suppression without that cost, while its own help text confirms auth stays + untouched. +- **`--strict-mcp-config`** is largely redundant with what `--safe-mode` + already does — verified by inspecting the `stream-json` init event's + `mcp_servers` field, which comes back empty under `--safe-mode` alone — but + is kept as a second, essentially free guarantee: `--safe-mode`'s own help + text notes that admin-managed (policy) settings still apply, and it's + unclear whether that could include a managed MCP server. +- **`--system-prompt `**, not `--append-system-prompt`. Appending leaves + the default agent system prompt in place, and the model still believes it + has tools even when none are actually wired up — verified: with + `--safe-mode --tools ""` but the prompt still appended, a "list the files" + request came back emitting fake `...` text instead of a + plain-English answer. `--system-prompt` replaces the prompt outright, which + fixes that. When the request carries no system message, ccal does **not** + pass `--system-prompt ""` or omit the flag — both were tested and both fall + back to the same broken agent-framing behaviour as appending (and a + whitespace-only prompt is rejected outright by the underlying API: "system: + text content blocks must contain non-whitespace text"). Instead it + substitutes a small neutral default system prompt. + +`GET /v1/models` also reflects vanilla mode: `supported_parameters` drops +`"tools"` for every model, since the backing agent has none in this mode (see +below). + +Two practical details (both modes): - **PATH augmentation.** claude shells out to helper binaries that may live alongside it, so ccal prepends the binary's own directory to `PATH` for the @@ -185,6 +243,15 @@ Returns a static OpenAI-shaped list of exactly three ids — `sonnet`, `opus`, `object: "model"`, `owned_by: "ccal"`, and a stable `created: 0` so responses are deterministic. The default model when a request omits `model` is `sonnet`. +`supported_parameters` is catalog metadata about what the backing agent can do +while producing a response, not a literal OpenAI tool-calling contract ccal +implements (it never reads a `tools` field off the request). `listModels()` +takes the server's `vanilla` flag and, when set, filters `"tools"` out of every +model's `supported_parameters` — advertising it would describe a capability +that mode's request path can't actually exercise. `"reasoning"` (extended +thinking) is unaffected by tool availability and stays either way — verified: +thinking tokens still show up in vanilla responses from sonnet/opus. + ## CORS and Private Network Access By default CORS is intentionally permissive (`origin: "*"`) so local browser apps diff --git a/docs/development.md b/docs/development.md index 90ce191..db1444c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,6 +87,27 @@ You should see `data: {...}` SSE frames followed by `data: [DONE]`. > chat endpoint needs `claude` installed and logged in; if it can't be found you > get a `502` with a clear message. +**Vanilla mode:** start the server with `--vanilla` and a request that would +otherwise use a tool should instead get a plain-English answer with no tool +activity: + +```sh +node dist/cli.js serve --port 8787 --vanilla +curl http://127.0.0.1:8787/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "sonnet", + "messages": [{ "role": "user", "content": "List the files in the current directory." }] + }' +``` + +Compare against the same request without `--vanilla`: the agentic server +actually runs a tool and returns real file names (plus a `*↳ Bash: ...*` note); +the vanilla server says it can't do that and asks you to run the command +yourself. That contrast is the whole point of the flag — see [the README +section](../README.md#vanilla-mode-no-tools-no-mcp-no-claudemd) for what +changes and why. + If auto-detection doesn't find your binary, set the override: ```sh diff --git a/src/claude.ts b/src/claude.ts index 87031e9..048052c 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -8,13 +8,19 @@ import { dirname, delimiter } from "node:path"; import type { ImageContent } from "./prompt.js"; +// The system prompt used in vanilla mode when the request carries no system +// message of its own. See the vanilla-mode block in spawnClaude() for why an +// empty or omitted --system-prompt is not good enough here. +const VANILLA_DEFAULT_SYSTEM_PROMPT = "You are a helpful, honest assistant."; + // Options controlling how the claude process is launched for one request. export interface SpawnOptions { // Absolute path to the resolved `claude` binary. claudePath: string; // The model alias to pass to `--model` (e.g. "sonnet"). model: string; - // Combined system prompt to append via `--append-system-prompt`, or null. + // Combined system prompt from the request, or null. In agentic mode this is + // appended to the default agent prompt; in vanilla mode it replaces it. system: string | null; // The flattened transcript to write to claude's stdin. stdinPayload: string; @@ -23,11 +29,18 @@ export interface SpawnOptions { // text input cannot carry them). Empty/omitted keeps the default text input. images?: ImageContent[]; // Working directory for the process. When set, claude loads tools, MCP - // servers, and CLAUDE.md relative to it. Defaults to the current directory. + // servers, and CLAUDE.md relative to it (agentic mode only — vanilla mode + // disables all three regardless of cwd). Defaults to the current directory. cwd?: string; // Optional permission mode. Only forwarded when it is a non-empty, non-default - // value, matching the Rust adapter. + // value, matching the Rust adapter. Meaningless in vanilla mode (there are no + // tools to grant permission for), but still forwarded — it's harmless and it + // keeps the flag-building logic in one place. permissionMode?: string; + // Run this request as a plain model call instead of a full Claude Code agent: + // no built-in tools, no MCP servers, no CLAUDE.md, no agent system prompt. See + // the vanilla-mode block below for exactly what that changes and why. + vanilla?: boolean; } // A running claude process together with a line iterator over its stdout. @@ -38,14 +51,18 @@ export interface ClaudeProcess { lines: AsyncIterable; } -// Build an agentic claude invocation and start writing the prompt to its stdin. +// Build a claude invocation and start writing the prompt to its stdin. +// +// By default this runs as a full Claude Code agent: tools, MCP servers, and +// CLAUDE.md all load based on `cwd` (we deliberately do not pass `--bare`, +// `--strict-mcp-config`, or restrict `--setting-sources`). Any system content is +// *appended* to the default agent prompt via `--append-system-prompt` so tool +// use survives. Input is a single text prompt on stdin (the CLI's default input +// format) — one prompt in, one response out. // -// Runs as a full Claude Code agent: tools, MCP servers, and CLAUDE.md all load -// based on `cwd` (we deliberately do not pass `--bare`, `--strict-mcp-config`, -// or restrict `--setting-sources`). Any system content is *appended* to the -// default agent prompt via `--append-system-prompt` so tool use survives. Input -// is a single text prompt on stdin (the CLI's default input format) — one prompt -// in, one response out. +// `options.vanilla` switches all of that off — see the block below. This is for +// callers (like opencode) that run their own tool loop and would otherwise end +// up fighting a second, independent agent underneath ccal. export function spawnClaude(options: SpawnOptions): ClaudeProcess { const hasImages = !!options.images && options.images.length > 0; @@ -68,7 +85,70 @@ export function spawnClaude(options: SpawnOptions): ClaudeProcess { args.push("--input-format", "stream-json"); } - if (options.system !== null && options.system.length > 0) { + if (options.vanilla) { + // --- Vanilla mode: a plain model call, no agent framing. --- + // + // Four flags, each earning its place (verified against `claude --help`, + // not assumed): + // + // 1. `--tools ""` disables every built-in tool. Per the CLI's own help, + // "" means "disable all tools". This alone does NOT stop CLAUDE.md or + // MCP servers from loading (verified: with only --tools "" a CLAUDE.md + // in --cwd still leaked into the response) — it just means the model + // has nothing to call. + // + // 2. `--safe-mode`, NOT `--bare`. The obvious pick here is `--bare` (it + // turns off CLAUDE.md auto-discovery, hooks, plugins, ...), and that + // was the starting assumption. It's wrong: --bare's own help says + // Anthropic auth becomes strictly ANTHROPIC_API_KEY / apiKeyHelper, + // "OAuth and keychain are never read". Verified by running `claude -p + // --bare` on a box authenticated via subscription login (no API key + // set): it fails with "Not logged in - Please run /login", even though + // the identical prompt without --bare succeeds. That would break ccal + // for exactly the users it exists for — the whole point of ccal (see + // README) is driving the CLI's existing subscription login without an + // API key. `--safe-mode` gets us the part we actually need: its help + // text says it disables CLAUDE.md, skills, plugins, hooks, and MCP + // servers, while explicitly leaving "auth, model selection, built-in + // tools, and permissions" working normally. Verified: a CLAUDE.md that + // leaks under plain --tools "" no longer does under --safe-mode + // --tools "", and the request still succeeds against a subscription + // login. + // + // 3. `--strict-mcp-config`. In this environment --safe-mode alone already + // empties the MCP server list (verified via the stream-json init + // event), which would make this redundant. But --safe-mode's own help + // text carves out an exception — "Admin-managed (policy) settings + // still apply" — and it's not clear that carve-out excludes an + // org-managed MCP server. --strict-mcp-config costs nothing when there + // is nothing to filter (no --mcp-config is passed), so it stays as a + // second, explicit guarantee rather than leaning on --safe-mode's + // behavior alone. + // + // 4. `--system-prompt` instead of `--append-system-prompt`. Appending + // leaves Claude Code's own agent system prompt in place, and even with + // tools disabled the model still believes it's an agent with tools — + // verified: with --safe-mode --tools "" but the prompt still appended, + // a "list the files" request came back emitting fake + // `...` blocks instead of a plain-English answer. + // `--system-prompt` replaces the prompt outright, which fixes that. + // + // The subtlety: when the caller sends no system message, DO NOT pass + // `--system-prompt ""` or omit the flag. Both were tested and both + // fall back to the same broken behavior as appending — the model still + // hallucinates tool calls (and in one run, invented files that don't + // exist). A single whitespace-only string is worse still — the + // underlying API rejects it outright ("system: text content blocks + // must contain non-whitespace text"). So when there's no system + // message from the caller, we substitute a small neutral default + // (VANILLA_DEFAULT_SYSTEM_PROMPT) rather than leaving the flag out. + args.push("--tools", "", "--safe-mode", "--strict-mcp-config"); + const vanillaSystem = + options.system !== null && options.system.length > 0 + ? options.system + : VANILLA_DEFAULT_SYSTEM_PROMPT; + args.push("--system-prompt", vanillaSystem); + } else if (options.system !== null && options.system.length > 0) { args.push("--append-system-prompt", options.system); } diff --git a/src/cli.ts b/src/cli.ts index fdf8986..2be700d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ // The `ccal` command-line entry point. It supports a single `serve` subcommand // that starts the OpenAI-compatible HTTP server: // -// ccal serve [--port ] [--host ] [--cwd ] [--permission-mode ] +// ccal serve [--port ] [--host ] [--cwd ] [--permission-mode ] [--vanilla] // // We keep argument parsing deliberately small and dependency-free — the surface // is tiny, so a hand-rolled parser is clearer than pulling in a CLI framework. @@ -74,6 +74,11 @@ interface ServeOptions { // Repeatable, empty by default — see the Private Network Access note in // server.ts for why this is opt-in rather than always on. allowedOrigins: string[]; + // Serve every request as a plain model call instead of a full Claude Code + // agent — no tools, no MCP servers, no CLAUDE.md, no agent system prompt. + // Off by default; see the vanilla-mode block in claude.ts's spawnClaude() + // for exactly what it changes and why. + vanilla: boolean; } // Default port and host, chosen to match the value documented in the README and @@ -88,6 +93,7 @@ function parseServeArgs(args: string[]): ServeOptions { port: DEFAULT_PORT, host: DEFAULT_HOST, allowedOrigins: [], + vanilla: false, }; for (let i = 0; i < args.length; i++) { @@ -132,6 +138,13 @@ function parseServeArgs(args: string[]): ServeOptions { case "--permission-mode": options.permissionMode = takeValue(); break; + case "--vanilla": + // Boolean flag: presence means on. It never consumes the next argv + // item as a value (unlike --port/--cwd/etc above) — only the + // --vanilla=false inline form can turn it back off, for symmetry with + // the other flags' --flag=value syntax. + options.vanilla = inlineValue === undefined ? true : inlineValue !== "false"; + break; case "--allow-origin": { // Validated here rather than at use, so a typo is a startup error and // not a silent "why is my site still blocked" ten minutes later. An @@ -195,6 +208,11 @@ function printHelp(): void { " Access rules; without it such a request just hangs.", " Only name sites you trust — they can drive your", " claude CLI. e.g. --allow-origin https://example.com", + " --vanilla Serve plain model calls instead of a full Claude", + " Code agent: no tools, no MCP servers, no CLAUDE.md,", + " no agent system prompt. For callers that run their", + " own tool loop (e.g. opencode) and would otherwise", + " fight a second agent underneath ccal. Off by default.", " --help Show this help", "", "Once running, point any OpenAI-compatible client at:", @@ -246,6 +264,7 @@ async function main(): Promise { cwd: options.cwd, permissionMode: options.permissionMode, allowedOrigins: options.allowedOrigins, + vanilla: options.vanilla, }; const app = createServer(config); @@ -260,6 +279,13 @@ async function main(): Promise { `Allowing public sites to reach this server: ${options.allowedOrigins.join(", ")}`, ); } + if (options.vanilla) { + // Printed for the same reason as the allow-origin note above: it is a + // deliberate, visible behaviour change, not a silent default. + console.log( + "Vanilla mode: serving plain model calls (no tools, no MCP servers, no CLAUDE.md).", + ); + } if (claude.available) { const version = claude.version ? ` (${claude.version})` : ""; console.log(`Using claude CLI at ${claude.path}${version}`); diff --git a/src/models.ts b/src/models.ts index f862885..b9e0cea 100644 --- a/src/models.ts +++ b/src/models.ts @@ -66,11 +66,25 @@ export interface ModelObject { } // Build the full OpenAI-shaped model list payload. -export function listModels(): { object: "list"; data: ModelObject[] } { +// +// `vanilla` mirrors the server's --vanilla flag. `supported_parameters` is +// catalog metadata about what the *backing agent* can do while producing a +// response, not a literal OpenAI tool-calling contract ccal implements itself +// (it doesn't read a `tools` field off the request at all). In vanilla mode +// the claude invocation runs with all tools disabled (see spawnClaude()'s +// vanilla block in claude.ts), so advertising "tools" there would describe a +// capability that request path cannot actually exercise. We drop it rather +// than leave a metadata-aware client to discover the gap by trying. Reasoning +// (extended thinking) is unaffected by tool availability — verified: thinking +// tokens still show up in vanilla responses from sonnet/opus — so it stays. +export function listModels(vanilla = false): { object: "list"; data: ModelObject[] } { return { object: "list", data: MODEL_IDS.map((id) => { const meta = MODEL_META[id]; + const supportedParameters = vanilla + ? meta.supportedParameters.filter((p) => p !== "tools") + : meta.supportedParameters; return { id, object: "model", @@ -81,7 +95,7 @@ export function listModels(): { object: "list"; data: ModelObject[] } { input_modalities: meta.inputModalities, output_modalities: ["text"], }, - supported_parameters: meta.supportedParameters, + supported_parameters: supportedParameters, description: meta.description, }; }), diff --git a/src/server.ts b/src/server.ts index 2e1fdac..d459988 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,6 +32,12 @@ export interface AdapterConfig { // internet — see the Private Network Access note in `createServer`. Empty or // omitted turns the feature off, which is the default and the safe posture. allowedOrigins?: string[]; + // Run every request as a plain model call instead of a full Claude Code + // agent: no tools, no MCP servers, no CLAUDE.md, no agent system prompt. See + // the vanilla-mode block in claude.ts's spawnClaude() for exactly what that + // changes and why. Off by default — existing consumers rely on the agentic + // behaviour, which stays completely unchanged when this is unset. + vanilla?: boolean; } // Generate a unique-ish completion id. We use the high-resolution clock in @@ -115,8 +121,10 @@ export function createServer(config: AdapterConfig = {}): Hono { // Simple liveness probe. app.get("/health", (c) => c.json({ ok: true })); - // The OpenAI model list. Mirrors the ids the CLI understands. - app.get("/v1/models", (c) => c.json(listModels())); + // The OpenAI model list. Mirrors the ids the CLI understands. In vanilla + // mode the `tools` capability is dropped from `supported_parameters` — see + // the comment on listModels() in models.ts for why. + app.get("/v1/models", (c) => c.json(listModels(config.vanilla))); // The OpenAI chat-completions endpoint. app.post("/v1/chat/completions", async (c) => { @@ -151,6 +159,7 @@ export function createServer(config: AdapterConfig = {}): Hono { images, cwd: config.cwd, permissionMode: config.permissionMode, + vanilla: config.vanilla, }); const id = genId();