@elvatis_com/openclaw-cli-bridge-elvatis 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # openclaw-cli-bridge-elvatis
2
2
 
3
- > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching.
3
+ > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, and health testing.
4
4
 
5
- **Current version:** `0.2.2`
5
+ **Current version:** `0.2.5`
6
6
 
7
7
  ---
8
8
 
@@ -14,30 +14,59 @@ Registers the `openai-codex` provider by reading OAuth tokens already stored by
14
14
  ### Phase 2 — Request bridge (local proxy)
15
15
  Starts a local OpenAI-compatible HTTP proxy on `127.0.0.1:31337` and configures OpenClaw's `vllm` provider to route calls through `gemini` and `claude` CLI subprocesses.
16
16
 
17
- Prompt delivery: always via **stdin** (not CLI args) — avoids `E2BIG` for long sessions. Each message batch is truncated to the last 20 messages + system message (configurable in `src/cli-runner.ts`).
17
+ **Prompt delivery:** always via **stdin** (never CLI args or `@file`) — avoids `E2BIG` for long sessions and Gemini agentic mode. Each message batch is truncated to the last 20 messages + system message (`MAX_MESSAGES`/`MAX_MSG_CHARS` in `src/cli-runner.ts`).
18
18
 
19
- | Model reference | CLI invoked |
20
- |---|---|
21
- | `vllm/cli-gemini/gemini-2.5-pro` | `gemini -m gemini-2.5-pro @<tmpfile>` |
22
- | `vllm/cli-gemini/gemini-2.5-flash` | `gemini -m gemini-2.5-flash @<tmpfile>` |
23
- | `vllm/cli-gemini/gemini-3-pro` | `gemini -m gemini-3-pro @<tmpfile>` |
24
- | `vllm/cli-claude/claude-sonnet-4-6` | `claude -p --output-format text --model claude-sonnet-4-6` (stdin) |
25
- | `vllm/cli-claude/claude-opus-4-6` | `claude -p --output-format text --model claude-opus-4-6` (stdin) |
26
- | `vllm/cli-claude/claude-haiku-4-5` | `claude -p --output-format text --model claude-haiku-4-5` (stdin) |
19
+ | Model reference | CLI invoked | Latency |
20
+ |---|---|---|
21
+ | `vllm/cli-gemini/gemini-2.5-pro` | `gemini -m gemini-2.5-pro -p ""` (stdin, cwd=/tmp) | ~8–10s |
22
+ | `vllm/cli-gemini/gemini-2.5-flash` | `gemini -m gemini-2.5-flash -p ""` (stdin, cwd=/tmp) | ~4–6s |
23
+ | `vllm/cli-gemini/gemini-3-pro` | `gemini -m gemini-3-pro -p ""` (stdin, cwd=/tmp) | ~8–10s |
24
+ | `vllm/cli-claude/claude-sonnet-4-6` | `claude -p --output-format text --model claude-sonnet-4-6` (stdin) | ~2–4s |
25
+ | `vllm/cli-claude/claude-opus-4-6` | `claude -p --output-format text --model claude-opus-4-6` (stdin) | ~3–5s |
26
+ | `vllm/cli-claude/claude-haiku-4-5` | `claude -p --output-format text --model claude-haiku-4-5` (stdin) | ~1–3s |
27
27
 
28
28
  ### Phase 3 — Slash commands
29
- Six plugin-registered commands for instant model switching (no agent invocation needed):
29
+ Ten plugin-registered commands (all `requireAuth: true`):
30
+
31
+ **Claude Code CLI** (routed via local proxy on `:31337`):
30
32
 
31
- | Command | Switches to |
33
+ | Command | Model |
32
34
  |---|---|
33
35
  | `/cli-sonnet` | `vllm/cli-claude/claude-sonnet-4-6` |
34
36
  | `/cli-opus` | `vllm/cli-claude/claude-opus-4-6` |
35
37
  | `/cli-haiku` | `vllm/cli-claude/claude-haiku-4-5` |
38
+
39
+ **Gemini CLI** (routed via local proxy on `:31337`, stdin + `cwd=/tmp`):
40
+
41
+ | Command | Model |
42
+ |---|---|
36
43
  | `/cli-gemini` | `vllm/cli-gemini/gemini-2.5-pro` |
37
44
  | `/cli-gemini-flash` | `vllm/cli-gemini/gemini-2.5-flash` |
38
45
  | `/cli-gemini3` | `vllm/cli-gemini/gemini-3-pro` |
39
46
 
40
- All commands require `requireAuth: true` — only authorized/owner senders can execute them. Each command calls `openclaw models set <model>` via `api.runtime.system.runCommandWithTimeout` and replies with a confirmation.
47
+ **Codex CLI** (via `openai-codex` provider Codex CLI OAuth auth, calls OpenAI API directly, **not** through the local proxy):
48
+
49
+ | Command | Model |
50
+ |---|---|
51
+ | `/cli-codex` | `openai-codex/gpt-5.3-codex` |
52
+ | `/cli-codex-mini` | `openai-codex/gpt-5.1-codex-mini` |
53
+
54
+ **Utility:**
55
+
56
+ | Command | What it does |
57
+ |---|---|
58
+ | `/cli-back` | Restore the model active **before** the last `/cli-*` switch |
59
+ | `/cli-test [model]` | One-shot proxy health check — **does NOT switch your active model** |
60
+
61
+ **`/cli-back` details:**
62
+ - Before every `/cli-*` switch the current model is saved to `~/.openclaw/cli-bridge-state.json`
63
+ - `/cli-back` reads it, calls `openclaw models set <previous>`, then clears the file
64
+ - State survives gateway restarts — safe to use any time
65
+
66
+ **`/cli-test` details:**
67
+ - Accepts short form (`cli-sonnet`) or full path (`vllm/cli-claude/claude-sonnet-4-6`)
68
+ - Default when no arg given: `cli-claude/claude-sonnet-4-6`
69
+ - Reports response content, latency, and confirms your active model is unchanged
41
70
 
42
71
  ---
43
72
 
@@ -57,7 +86,7 @@ All commands require `requireAuth: true` — only authorized/owner senders can e
57
86
  # From ClawHub
58
87
  clawhub install openclaw-cli-bridge-elvatis
59
88
 
60
- # Or from workspace (development / local path)
89
+ # Or from workspace (development)
61
90
  # Add to ~/.openclaw/openclaw.json:
62
91
  # plugins.load.paths: ["~/.openclaw/workspace/openclaw-cli-bridge-elvatis"]
63
92
  # plugins.entries.openclaw-cli-bridge-elvatis: { "enabled": true }
@@ -69,37 +98,61 @@ clawhub install openclaw-cli-bridge-elvatis
69
98
 
70
99
  ### 1. Enable + restart
71
100
 
72
- ```bash
73
- # In ~/.openclaw/openclaw.json → plugins.entries:
101
+ ```json
102
+ // ~/.openclaw/openclaw.json → plugins.entries
74
103
  "openclaw-cli-bridge-elvatis": { "enabled": true }
104
+ ```
75
105
 
106
+ ```bash
76
107
  openclaw gateway restart
77
108
  ```
78
109
 
79
- ### 2. Register Codex auth (Phase 1, optional)
110
+ ### 2. Verify (check gateway logs)
111
+
112
+ ```
113
+ [cli-bridge] proxy ready on :31337
114
+ [cli-bridge] registered 8 commands: /cli-sonnet, /cli-opus, /cli-haiku,
115
+ /cli-gemini, /cli-gemini-flash, /cli-gemini3, /cli-back, /cli-test
116
+ ```
117
+
118
+ ### 3. Register Codex auth (optional — Phase 1 only)
80
119
 
81
120
  ```bash
82
121
  openclaw models auth login --provider openai-codex
83
122
  # Select: "Codex CLI (existing login)"
84
123
  ```
85
124
 
86
- ### 3. Verify proxy (Phase 2)
87
-
88
- On startup the plugin auto-patches `openclaw.json` with the `vllm` provider config (port `31337`) and logs:
125
+ ### 4. Test without switching your model
89
126
 
90
127
  ```
91
- [cli-bridge] proxy ready — vllm/cli-gemini/* and vllm/cli-claude/* available
92
- [cli-bridge] registered 6 slash commands: /cli-sonnet, /cli-opus, /cli-haiku, /cli-gemini, /cli-gemini-flash, /cli-gemini3
128
+ /cli-test
129
+ 🧪 CLI Bridge Test
130
+ Model: vllm/cli-claude/claude-sonnet-4-6
131
+ Response: CLI bridge OK
132
+ Latency: 2531ms
133
+ Active model unchanged: anthropic/claude-sonnet-4-6
134
+
135
+ /cli-test cli-gemini
136
+ → 🧪 CLI Bridge Test
137
+ Model: vllm/cli-gemini/gemini-2.5-pro
138
+ Response: CLI bridge OK
139
+ Latency: 8586ms
140
+ Active model unchanged: anthropic/claude-sonnet-4-6
93
141
  ```
94
142
 
95
- ### 4. Switch models (Phase 3)
96
-
97
- Use any `/cli-*` command from any connected channel:
143
+ ### 5. Switch and restore
98
144
 
99
145
  ```
100
146
  /cli-sonnet
101
147
  → ✅ Switched to Claude Sonnet 4.6 (CLI)
102
148
  `vllm/cli-claude/claude-sonnet-4-6`
149
+ Use /cli-back to restore previous model.
150
+
151
+ ... test things ...
152
+
153
+ /cli-back
154
+ → ✅ Restored previous model
155
+ `anthropic/claude-sonnet-4-6`
103
156
  ```
104
157
 
105
158
  ---
@@ -125,17 +178,33 @@ In `~/.openclaw/openclaw.json` → `plugins.entries.openclaw-cli-bridge-elvatis.
125
178
  ```
126
179
  OpenClaw agent
127
180
 
128
- ├─ openai-codex/* ──► OpenAI API (auth via ~/.codex/auth.json OAuth tokens)
181
+ ├─ openai-codex/* ──────────────────────────► OpenAI API (direct)
182
+ │ auth: ~/.codex/auth.json OAuth tokens ▲
183
+ │ │
184
+ │ /cli-codex, /cli-codex-mini ─────────────────┘ (switch to this provider)
129
185
 
130
186
  └─ vllm/cli-gemini/* ─┐
131
187
  vllm/cli-claude/* ─┤─► localhost:31337 (openclaw-cli-bridge proxy)
132
- │ ├─ cli-gemini/* → gemini -m <model> @<tmpfile>
133
- └─ cli-claude/* → claude -p --model <model> ← prompt via stdin
188
+ │ ├─ cli-gemini/* → gemini -m <model> -p ""
189
+ │ stdin=prompt, cwd=/tmp
190
+ │ │ (neutral cwd prevents agentic mode)
191
+ │ └─ cli-claude/* → claude -p --model <model>
192
+ │ stdin=prompt
134
193
  └───────────────────────────────────────────────────
135
194
 
136
- Slash commands (bypass agent):
137
- /cli-sonnet|opus|haiku|gemini|gemini-flash|gemini3
138
- └─► openclaw models set <model> (atomic, ~1s)
195
+ Slash commands (bypass agent, requireAuth=true):
196
+ /cli-sonnet|opus|haiku|gemini|gemini-flash|gemini3|codex|codex-mini
197
+ └─► saves current model → ~/.openclaw/cli-bridge-state.json
198
+ └─► openclaw models set <model> (~1s, atomic)
199
+
200
+ /cli-back
201
+ └─► reads ~/.openclaw/cli-bridge-state.json
202
+ └─► openclaw models set <previous>
203
+
204
+ /cli-test [model]
205
+ └─► HTTP POST → localhost:31337 (no global model change)
206
+ └─► reports response + latency
207
+ └─► NOTE: only tests the proxy — Codex models bypass the proxy
139
208
  ```
140
209
 
141
210
  ---
@@ -143,12 +212,14 @@ Slash commands (bypass agent):
143
212
  ## Known Issues & Fixes
144
213
 
145
214
  ### `spawn E2BIG` (fixed in v0.2.1)
215
+ **Symptom:** `CLI error for cli-claude/…: spawn E2BIG` after ~500+ messages.
216
+ **Cause:** Gateway injects large values into `process.env` at runtime. Spreading it into `spawn()` exceeds Linux's `ARG_MAX` (~2MB).
217
+ **Fix:** `buildMinimalEnv()` — only passes `HOME`, `PATH`, `USER`, and auth keys.
146
218
 
147
- **Symptom:** `CLI error for cli-claude/…: spawn E2BIG` after ~500+ messages in a session.
148
-
149
- **Root cause:** The OpenClaw gateway modifies `process.env` at runtime (OPENCLAW_* vars, session context, etc.). Spreading the full `process.env` into `spawn()` pushes `argv + envp` over Linux's `ARG_MAX` (~2MB).
150
-
151
- **Fix:** `buildMinimalEnv()` in `src/cli-runner.ts` — only passes `HOME`, `PATH`, `USER`, and auth keys to the subprocess. Immune to gateway runtime env size.
219
+ ### Gemini agentic mode / hangs (fixed in v0.2.4)
220
+ **Symptom:** Gemini hangs, returns wrong answers, or says "directory does not exist".
221
+ **Cause:** `@file` syntax (`gemini -p @/tmp/xxx.txt`) triggers agentic mode Gemini scans the working directory for project context and treats prompts as task instructions. Running from the workspace root makes this worse.
222
+ **Fix:** Stdin delivery (`gemini -p ""` with prompt via stdin) + `cwd=/tmp`. Same pattern as Claude.
152
223
 
153
224
  ---
154
225
 
@@ -156,30 +227,39 @@ Slash commands (bypass agent):
156
227
 
157
228
  ```bash
158
229
  npm run typecheck # tsc --noEmit
159
- npm test # vitest run
230
+ npm test # vitest run (5 unit tests for formatPrompt)
160
231
  ```
161
232
 
162
- Test coverage: `test/cli-runner.test.ts` — unit tests for `formatPrompt` (truncation, system message handling, MAX_MSG_CHARS).
163
-
164
233
  ---
165
234
 
166
235
  ## Changelog
167
236
 
237
+ ### v0.2.5
238
+ - **feat:** `/cli-codex` → `openai-codex/gpt-5.3-codex`
239
+ - **feat:** `/cli-codex-mini` → `openai-codex/gpt-5.1-codex-mini`
240
+ - Codex commands use the `openai-codex` provider (Codex CLI OAuth auth, direct OpenAI API — not the local proxy)
241
+
242
+ ### v0.2.4
243
+ - **fix:** Gemini agentic mode — replaced `@file` with stdin delivery (`-p ""`) + `cwd=/tmp`
244
+ - **fix:** Filter `[WARN]` and `Loaded cached credentials` noise from Gemini stderr
245
+ - Added `RunCliOptions` interface with optional `cwd` field
246
+
247
+ ### v0.2.3
248
+ - **feat:** `/cli-back` — restore previous model (state persisted in `~/.openclaw/cli-bridge-state.json`)
249
+ - **feat:** `/cli-test [model]` — one-shot proxy health check without changing active model
250
+
168
251
  ### v0.2.2
169
252
  - **feat:** Phase 3 — `/cli-*` slash commands for instant model switching
170
- - All 6 commands registered via `api.registerCommand` with `requireAuth: true`
171
- - Calls `openclaw models set <model>` via `api.runtime.system.runCommandWithTimeout`
253
+ - All 6 model commands via `api.registerCommand` with `requireAuth: true`
172
254
 
173
255
  ### v0.2.1
174
- - **fix:** `spawn E2BIG` — use `buildMinimalEnv()` instead of spreading full `process.env`
175
- - **feat:** Added `test/cli-runner.test.ts` (5 unit tests)
176
- - Added Gemini 3 Pro model (`vllm/cli-gemini/gemini-3-pro`)
256
+ - **fix:** `spawn E2BIG` — `buildMinimalEnv()` instead of spreading full `process.env`
257
+ - **feat:** Unit tests (`test/cli-runner.test.ts`)
177
258
 
178
259
  ### v0.2.0
179
260
  - **feat:** Phase 2 — local OpenAI-compatible proxy server
180
- - Prompt via stdin/tmpfile (never as CLI arg) to prevent arg-size issues
181
- - `MAX_MESSAGES=20` + `MAX_MSG_CHARS=4000` truncation in `formatPrompt`
182
- - Auto-patch of `openclaw.json` vllm provider config on first start
261
+ - Stdin prompt delivery, `MAX_MESSAGES=20` + `MAX_MSG_CHARS=4000` truncation
262
+ - Auto-patch of `openclaw.json` vllm provider config
183
263
 
184
264
  ### v0.1.x
185
265
  - Phase 1: Codex CLI OAuth auth bridge
package/index.ts CHANGED
@@ -9,12 +9,14 @@
9
9
  * are handled by the Gemini CLI and Claude Code CLI subprocesses.
10
10
  *
11
11
  * Phase 3 (slash commands): registers /cli-* commands for instant model switching.
12
- * /cli-sonnet → vllm/cli-claude/claude-sonnet-4-6
13
- * /cli-opus → vllm/cli-claude/claude-opus-4-6
14
- * /cli-haiku → vllm/cli-claude/claude-haiku-4-5
15
- * /cli-gemini → vllm/cli-gemini/gemini-2.5-pro
16
- * /cli-gemini-flash → vllm/cli-gemini/gemini-2.5-flash
17
- * /cli-gemini3 → vllm/cli-gemini/gemini-3-pro
12
+ * /cli-sonnet → vllm/cli-claude/claude-sonnet-4-6 (Claude Code CLI proxy)
13
+ * /cli-opus → vllm/cli-claude/claude-opus-4-6 (Claude Code CLI proxy)
14
+ * /cli-haiku → vllm/cli-claude/claude-haiku-4-5 (Claude Code CLI proxy)
15
+ * /cli-gemini → vllm/cli-gemini/gemini-2.5-pro (Gemini CLI proxy)
16
+ * /cli-gemini-flash → vllm/cli-gemini/gemini-2.5-flash (Gemini CLI proxy)
17
+ * /cli-gemini3 → vllm/cli-gemini/gemini-3-pro (Gemini CLI proxy)
18
+ * /cli-codex → openai-codex/gpt-5.3-codex (Codex CLI OAuth, direct API)
19
+ * /cli-codex-mini → openai-codex/gpt-5.1-codex-mini (Codex CLI OAuth, direct API)
18
20
  * /cli-back → restore model that was active before last /cli-* switch
19
21
  * /cli-test [model] → one-shot proxy health check (does NOT switch global model)
20
22
  *
@@ -116,42 +118,57 @@ function readCurrentModel(): string | null {
116
118
  // Phase 3: model command table
117
119
  // ──────────────────────────────────────────────────────────────────────────────
118
120
  const CLI_MODEL_COMMANDS = [
121
+ // ── Claude (via local proxy → Claude Code CLI) ──────────────────────────────
119
122
  {
120
123
  name: "cli-sonnet",
121
124
  model: "vllm/cli-claude/claude-sonnet-4-6",
122
- description: "Switch to Claude Sonnet 4.6 (CLI bridge)",
125
+ description: "Switch to Claude Sonnet 4.6 (Claude Code CLI via local proxy)",
123
126
  label: "Claude Sonnet 4.6 (CLI)",
124
127
  },
125
128
  {
126
129
  name: "cli-opus",
127
130
  model: "vllm/cli-claude/claude-opus-4-6",
128
- description: "Switch to Claude Opus 4.6 (CLI bridge)",
131
+ description: "Switch to Claude Opus 4.6 (Claude Code CLI via local proxy)",
129
132
  label: "Claude Opus 4.6 (CLI)",
130
133
  },
131
134
  {
132
135
  name: "cli-haiku",
133
136
  model: "vllm/cli-claude/claude-haiku-4-5",
134
- description: "Switch to Claude Haiku 4.5 (CLI bridge)",
137
+ description: "Switch to Claude Haiku 4.5 (Claude Code CLI via local proxy)",
135
138
  label: "Claude Haiku 4.5 (CLI)",
136
139
  },
140
+ // ── Gemini (via local proxy → Gemini CLI) ───────────────────────────────────
137
141
  {
138
142
  name: "cli-gemini",
139
143
  model: "vllm/cli-gemini/gemini-2.5-pro",
140
- description: "Switch to Gemini 2.5 Pro (CLI bridge)",
144
+ description: "Switch to Gemini 2.5 Pro (Gemini CLI via local proxy)",
141
145
  label: "Gemini 2.5 Pro (CLI)",
142
146
  },
143
147
  {
144
148
  name: "cli-gemini-flash",
145
149
  model: "vllm/cli-gemini/gemini-2.5-flash",
146
- description: "Switch to Gemini 2.5 Flash (CLI bridge)",
150
+ description: "Switch to Gemini 2.5 Flash (Gemini CLI via local proxy)",
147
151
  label: "Gemini 2.5 Flash (CLI)",
148
152
  },
149
153
  {
150
154
  name: "cli-gemini3",
151
155
  model: "vllm/cli-gemini/gemini-3-pro",
152
- description: "Switch to Gemini 3 Pro (CLI bridge)",
156
+ description: "Switch to Gemini 3 Pro (Gemini CLI via local proxy)",
153
157
  label: "Gemini 3 Pro (CLI)",
154
158
  },
159
+ // ── Codex (via openai-codex provider — Codex CLI OAuth auth, direct API) ────
160
+ {
161
+ name: "cli-codex",
162
+ model: "openai-codex/gpt-5.3-codex",
163
+ description: "Switch to GPT-5.3 Codex (openai-codex provider, Codex CLI auth)",
164
+ label: "GPT-5.3 Codex",
165
+ },
166
+ {
167
+ name: "cli-codex-mini",
168
+ model: "openai-codex/gpt-5.1-codex-mini",
169
+ description: "Switch to GPT-5.1 Codex Mini (openai-codex provider, Codex CLI auth)",
170
+ label: "GPT-5.1 Codex Mini",
171
+ },
155
172
  ] as const;
156
173
 
157
174
  /** Default model used by /cli-test when no arg is given */
@@ -260,7 +277,7 @@ function proxyTestRequest(
260
277
  const plugin = {
261
278
  id: "openclaw-cli-bridge-elvatis",
262
279
  name: "OpenClaw CLI Bridge",
263
- version: "0.2.3",
280
+ version: "0.2.5",
264
281
  description:
265
282
  "Phase 1: openai-codex auth bridge. " +
266
283
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.3",
4
+ "version": "0.2.5",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cli-runner.ts CHANGED
@@ -4,15 +4,16 @@
4
4
  * Spawns CLI subprocesses (gemini, claude) and captures their output.
5
5
  * Input: OpenAI-format messages → formatted prompt string → CLI stdin.
6
6
  *
7
- * IMPORTANT: Prompt is always passed via stdin (not as a CLI argument) to
8
- * avoid E2BIG ("Argument list too long") when conversation history is large.
7
+ * Both Gemini and Claude receive the prompt via stdin to avoid:
8
+ * - E2BIG (arg list too long) for large conversation histories
9
+ * - Gemini agentic mode (triggered by @file syntax + workspace cwd)
10
+ *
11
+ * Gemini is always spawned with cwd = tmpdir() so it doesn't scan the
12
+ * workspace and enter agentic mode.
9
13
  */
10
14
 
11
15
  import { spawn } from "node:child_process";
12
- import { writeFileSync, unlinkSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { join } from "node:path";
15
- import { randomBytes } from "node:crypto";
16
+ import { tmpdir, homedir } from "node:os";
16
17
 
17
18
  /** Max messages to include in the prompt sent to the CLI. */
18
19
  const MAX_MESSAGES = 20;
@@ -31,7 +32,7 @@ export interface ChatMessage {
31
32
  /**
32
33
  * Convert OpenAI messages to a single flat prompt string.
33
34
  * Truncates to MAX_MESSAGES (keeping the most recent) and MAX_MSG_CHARS per
34
- * message to avoid E2BIG when conversation history is very large.
35
+ * message to avoid oversized payloads.
35
36
  */
36
37
  export function formatPrompt(messages: ChatMessage[]): string {
37
38
  if (messages.length === 0) return "";
@@ -42,7 +43,7 @@ export function formatPrompt(messages: ChatMessage[]): string {
42
43
  const recent = nonSystem.slice(-MAX_MESSAGES);
43
44
  const truncated = system ? [system, ...recent] : recent;
44
45
 
45
- // If single user message with short content, send directly no wrapping.
46
+ // Single short user message send bare (no wrapping needed)
46
47
  if (truncated.length === 1 && truncated[0].role === "user") {
47
48
  return truncateContent(truncated[0].content);
48
49
  }
@@ -51,13 +52,10 @@ export function formatPrompt(messages: ChatMessage[]): string {
51
52
  .map((m) => {
52
53
  const content = truncateContent(m.content);
53
54
  switch (m.role) {
54
- case "system":
55
- return `[System]\n${content}`;
56
- case "assistant":
57
- return `[Assistant]\n${content}`;
55
+ case "system": return `[System]\n${content}`;
56
+ case "assistant": return `[Assistant]\n${content}`;
58
57
  case "user":
59
- default:
60
- return `[User]\n${content}`;
58
+ default: return `[User]\n${content}`;
61
59
  }
62
60
  })
63
61
  .join("\n\n");
@@ -69,40 +67,26 @@ function truncateContent(s: string): string {
69
67
  }
70
68
 
71
69
  // ──────────────────────────────────────────────────────────────────────────────
72
- // Core subprocess runner
70
+ // Minimal environment for spawned subprocesses
73
71
  // ──────────────────────────────────────────────────────────────────────────────
74
72
 
75
- export interface CliRunResult {
76
- stdout: string;
77
- stderr: string;
78
- exitCode: number;
79
- }
80
-
81
73
  /**
82
74
  * Build a minimal, safe environment for spawning CLI subprocesses.
83
75
  *
84
- * WHY: The OpenClaw gateway may inject large values into process.env at
85
- * runtime (system prompts, session data, OPENCLAW_* vars, etc.). Spreading
86
- * the full process.env into spawn() can push the combined argv+envp over
87
- * ARG_MAX (~2 MB on Linux), causing "spawn E2BIG". Using only the vars that
76
+ * WHY: The OpenClaw gateway modifies process.env at runtime (OPENCLAW_* vars,
77
+ * session context, etc.). Spreading the full process.env into spawn() can push
78
+ * argv+envp over ARG_MAX (~2 MB on Linux) "spawn E2BIG". Only passing what
88
79
  * the CLI tools actually need keeps us well under the limit regardless of
89
- * what the parent process environment contains.
80
+ * gateway runtime state.
90
81
  */
91
82
  function buildMinimalEnv(): Record<string, string> {
92
- const pick = (key: string): string | undefined => process.env[key];
93
-
94
- const env: Record<string, string> = {
95
- NO_COLOR: "1",
96
- TERM: "dumb",
97
- };
83
+ const pick = (key: string) => process.env[key];
84
+ const env: Record<string, string> = { NO_COLOR: "1", TERM: "dumb" };
98
85
 
99
- // Essential path/identity vars — always include when present.
100
86
  for (const key of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP"]) {
101
87
  const v = pick(key);
102
88
  if (v) env[key] = v;
103
89
  }
104
-
105
- // Allow google-auth / claude auth paths to be inherited.
106
90
  for (const key of [
107
91
  "GOOGLE_APPLICATION_CREDENTIALS",
108
92
  "ANTHROPIC_API_KEY",
@@ -120,37 +104,56 @@ function buildMinimalEnv(): Record<string, string> {
120
104
  return env;
121
105
  }
122
106
 
107
+ // ──────────────────────────────────────────────────────────────────────────────
108
+ // Core subprocess runner
109
+ // ──────────────────────────────────────────────────────────────────────────────
110
+
111
+ export interface CliRunResult {
112
+ stdout: string;
113
+ stderr: string;
114
+ exitCode: number;
115
+ }
116
+
117
+ export interface RunCliOptions {
118
+ /**
119
+ * Working directory for the subprocess.
120
+ * Defaults to homedir() — a neutral dir that won't trigger agentic context scanning.
121
+ */
122
+ cwd?: string;
123
+ timeoutMs?: number;
124
+ }
125
+
123
126
  /**
124
- * Spawn a CLI and deliver the prompt via stdin (not as an argument).
125
- * This avoids E2BIG ("Argument list too long") for large conversation histories
126
- * or when the parent process has a large runtime environment.
127
+ * Spawn a CLI and deliver the prompt via stdin.
128
+ *
129
+ * cwd defaults to homedir() so CLIs that scan the working directory for
130
+ * project context (like Gemini) don't accidentally enter agentic mode.
127
131
  */
128
132
  export function runCli(
129
133
  cmd: string,
130
134
  args: string[],
131
135
  prompt: string,
132
- timeoutMs = 120_000
136
+ timeoutMs = 120_000,
137
+ opts: RunCliOptions = {}
133
138
  ): Promise<CliRunResult> {
139
+ const cwd = opts.cwd ?? homedir();
140
+
134
141
  return new Promise((resolve, reject) => {
135
142
  const proc = spawn(cmd, args, {
136
143
  timeout: timeoutMs,
137
144
  env: buildMinimalEnv(),
145
+ cwd,
138
146
  });
139
147
 
140
148
  let stdout = "";
141
149
  let stderr = "";
142
150
 
143
- // Write prompt to stdin then close — prevents the CLI from waiting for more input.
144
151
  proc.stdin.write(prompt, "utf8", () => {
145
152
  proc.stdin.end();
146
153
  });
147
154
 
148
- proc.stdout.on("data", (d: Buffer) => {
149
- stdout += d.toString();
150
- });
151
- proc.stderr.on("data", (d: Buffer) => {
152
- stderr += d.toString();
153
- });
155
+ proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
156
+ proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
154
157
 
155
158
  proc.on("close", (code) => {
156
159
  resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 });
@@ -167,8 +170,19 @@ export function runCli(
167
170
  // ──────────────────────────────────────────────────────────────────────────────
168
171
 
169
172
  /**
170
- * Run: gemini -m <modelId> -p "<prompt>"
171
- * Strips the model prefix ("cli-gemini/gemini-2.5-pro" → "gemini-2.5-pro").
173
+ * Run Gemini CLI in headless mode with prompt delivered via stdin.
174
+ *
175
+ * WHY stdin (not @file):
176
+ * The @file syntax (`gemini -p @/tmp/xxx.txt`) triggers Gemini's agentic
177
+ * mode — it scans the current working directory for project context and
178
+ * interprets the prompt as a task instruction, not a Q&A. This causes hangs,
179
+ * wrong answers, and "directory does not exist" errors when run from a
180
+ * project workspace.
181
+ *
182
+ * Gemini CLI: -p "" triggers headless mode; stdin content is the actual prompt
183
+ * (per Gemini docs: "prompt is appended to input on stdin (if any)").
184
+ *
185
+ * cwd = tmpdir() — neutral empty-ish dir, prevents workspace context scanning.
172
186
  */
173
187
  export async function runGemini(
174
188
  prompt: string,
@@ -176,24 +190,22 @@ export async function runGemini(
176
190
  timeoutMs: number
177
191
  ): Promise<string> {
178
192
  const model = stripPrefix(modelId);
179
- // Gemini CLI doesn't support stdin write prompt to a temp file and read it via @file syntax
180
- const tmpFile = join(tmpdir(), `cli-bridge-${randomBytes(6).toString("hex")}.txt`);
181
- writeFileSync(tmpFile, prompt, "utf8");
182
- try {
183
- // Use @<file> to pass prompt from file (avoids ARG_MAX limit)
184
- const args = ["-m", model, "-p", `@${tmpFile}`];
185
- const result = await runCli("gemini", args, "", timeoutMs);
186
-
187
- if (result.exitCode !== 0 && result.stdout.length === 0) {
188
- throw new Error(
189
- `gemini exited ${result.exitCode}: ${result.stderr || "(no output)"}`
190
- );
191
- }
192
-
193
- return result.stdout || result.stderr;
194
- } finally {
195
- try { unlinkSync(tmpFile); } catch { /* ignore */ }
193
+ // -p "" = headless mode trigger; actual prompt arrives via stdin
194
+ const args = ["-m", model, "-p", ""];
195
+ const result = await runCli("gemini", args, prompt, timeoutMs, { cwd: tmpdir() });
196
+
197
+ // Filter out [WARN] lines from stderr (Gemini emits noisy permission warnings)
198
+ const cleanStderr = result.stderr
199
+ .split("\n")
200
+ .filter((l) => !l.startsWith("[WARN]") && !l.startsWith("Loaded cached"))
201
+ .join("\n")
202
+ .trim();
203
+
204
+ if (result.exitCode !== 0 && result.stdout.length === 0) {
205
+ throw new Error(`gemini exited ${result.exitCode}: ${cleanStderr || "(no output)"}`);
196
206
  }
207
+
208
+ return result.stdout || cleanStderr;
197
209
  }
198
210
 
199
211
  // ──────────────────────────────────────────────────────────────────────────────
@@ -201,7 +213,7 @@ export async function runGemini(
201
213
  // ──────────────────────────────────────────────────────────────────────────────
202
214
 
203
215
  /**
204
- * Run: claude -p --output-format text -m <modelId> "<prompt>"
216
+ * Run Claude Code CLI in headless mode with prompt delivered via stdin.
205
217
  * Strips the model prefix ("cli-claude/claude-opus-4-6" → "claude-opus-4-6").
206
218
  */
207
219
  export async function runClaude(
@@ -210,24 +222,17 @@ export async function runClaude(
210
222
  timeoutMs: number
211
223
  ): Promise<string> {
212
224
  const model = stripPrefix(modelId);
213
- // No prompt argument — deliver via stdin to avoid E2BIG
214
225
  const args = [
215
226
  "-p",
216
- "--output-format",
217
- "text",
218
- "--permission-mode",
219
- "plan",
220
- "--tools",
221
- "",
222
- "--model",
223
- model,
227
+ "--output-format", "text",
228
+ "--permission-mode", "plan",
229
+ "--tools", "",
230
+ "--model", model,
224
231
  ];
225
232
  const result = await runCli("claude", args, prompt, timeoutMs);
226
233
 
227
234
  if (result.exitCode !== 0 && result.stdout.length === 0) {
228
- throw new Error(
229
- `claude exited ${result.exitCode}: ${result.stderr || "(no output)"}`
230
- );
235
+ throw new Error(`claude exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
231
236
  }
232
237
 
233
238
  return result.stdout;
@@ -238,8 +243,7 @@ export async function runClaude(
238
243
  // ──────────────────────────────────────────────────────────────────────────────
239
244
 
240
245
  /**
241
- * Route a chat completion request to the right CLI based on the model name.
242
- * Model naming convention:
246
+ * Route a chat completion to the correct CLI based on model prefix.
243
247
  * cli-gemini/<id> → gemini CLI
244
248
  * cli-claude/<id> → claude CLI
245
249
  */
@@ -250,17 +254,11 @@ export async function routeToCliRunner(
250
254
  ): Promise<string> {
251
255
  const prompt = formatPrompt(messages);
252
256
 
253
- if (model.startsWith("cli-gemini/")) {
254
- return runGemini(prompt, model, timeoutMs);
255
- }
256
-
257
- if (model.startsWith("cli-claude/")) {
258
- return runClaude(prompt, model, timeoutMs);
259
- }
257
+ if (model.startsWith("cli-gemini/")) return runGemini(prompt, model, timeoutMs);
258
+ if (model.startsWith("cli-claude/")) return runClaude(prompt, model, timeoutMs);
260
259
 
261
260
  throw new Error(
262
- `Unknown CLI bridge model: "${model}". ` +
263
- `Use "cli-gemini/<model>" or "cli-claude/<model>".`
261
+ `Unknown CLI bridge model: "${model}". Use "cli-gemini/<model>" or "cli-claude/<model>".`
264
262
  );
265
263
  }
266
264
 
@@ -268,7 +266,6 @@ export async function routeToCliRunner(
268
266
  // Helpers
269
267
  // ──────────────────────────────────────────────────────────────────────────────
270
268
 
271
- /** Strip the "cli-gemini/" or "cli-claude/" prefix from a model ID. */
272
269
  function stripPrefix(modelId: string): string {
273
270
  const slash = modelId.indexOf("/");
274
271
  return slash === -1 ? modelId : modelId.slice(slash + 1);