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

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.
@@ -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.4",
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.4",
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);