@elvatis_com/openclaw-cli-bridge-elvatis 0.2.0 → 0.2.1

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/.clawhubignore ADDED
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .git/
3
+ dist/
4
+ *.tgz
5
+ npm-debug.log*
6
+ .vscode/
7
+ .idea/
package/SKILL.md ADDED
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: openclaw-cli-bridge-elvatis
3
+ description: Bridge local Codex, Gemini, and Claude Code CLIs into OpenClaw (Codex OAuth auth bridge + Gemini/Claude OpenAI-compatible local proxy via vllm).
4
+ homepage: https://github.com/elvatis/openclaw-cli-bridge-elvatis
5
+ metadata:
6
+ {
7
+ "openclaw":
8
+ {
9
+ "emoji": "🌉",
10
+ "requires": { "bins": ["openclaw", "codex", "gemini", "claude"] }
11
+ }
12
+ }
13
+ ---
14
+
15
+ # OpenClaw CLI Bridge Elvatis
16
+
17
+ This project provides two layers:
18
+
19
+ 1. **Codex auth bridge** for `openai-codex/*` by reading existing Codex CLI OAuth tokens from `~/.codex/auth.json`
20
+ 2. **Local OpenAI-compatible proxy** (default `127.0.0.1:31337`) for Gemini/Claude CLI execution via OpenClaw `vllm` provider models:
21
+ - `vllm/cli-gemini/*`
22
+ - `vllm/cli-claude/*`
23
+
24
+ See `README.md` for setup and architecture.
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
- "providers": ["openai-codex"],
6
+ "providers": [
7
+ "openai-codex"
8
+ ],
7
9
  "configSchema": {
8
10
  "type": "object",
9
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
@@ -2,10 +2,22 @@
2
2
  * cli-runner.ts
3
3
  *
4
4
  * Spawns CLI subprocesses (gemini, claude) and captures their output.
5
- * Input: OpenAI-format messages → formatted prompt string → CLI stdout.
5
+ * Input: OpenAI-format messages → formatted prompt string → CLI stdin.
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.
6
9
  */
7
10
 
8
11
  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
+
17
+ /** Max messages to include in the prompt sent to the CLI. */
18
+ const MAX_MESSAGES = 20;
19
+ /** Max characters per message content before truncation. */
20
+ const MAX_MSG_CHARS = 4000;
9
21
 
10
22
  // ──────────────────────────────────────────────────────────────────────────────
11
23
  // Message formatting
@@ -18,31 +30,44 @@ export interface ChatMessage {
18
30
 
19
31
  /**
20
32
  * Convert OpenAI messages to a single flat prompt string.
21
- * Both Gemini and Claude CLIs accept a plain text prompt.
33
+ * Truncates to MAX_MESSAGES (keeping the most recent) and MAX_MSG_CHARS per
34
+ * message to avoid E2BIG when conversation history is very large.
22
35
  */
23
36
  export function formatPrompt(messages: ChatMessage[]): string {
24
37
  if (messages.length === 0) return "";
25
38
 
26
- // If it's just a single user message, send it directly — no wrapping.
27
- if (messages.length === 1 && messages[0].role === "user") {
28
- return messages[0].content;
39
+ // Keep system message (if any) + last N non-system messages
40
+ const system = messages.find((m) => m.role === "system");
41
+ const nonSystem = messages.filter((m) => m.role !== "system");
42
+ const recent = nonSystem.slice(-MAX_MESSAGES);
43
+ const truncated = system ? [system, ...recent] : recent;
44
+
45
+ // If single user message with short content, send directly — no wrapping.
46
+ if (truncated.length === 1 && truncated[0].role === "user") {
47
+ return truncateContent(truncated[0].content);
29
48
  }
30
49
 
31
- return messages
50
+ return truncated
32
51
  .map((m) => {
52
+ const content = truncateContent(m.content);
33
53
  switch (m.role) {
34
54
  case "system":
35
- return `[System]\n${m.content}`;
55
+ return `[System]\n${content}`;
36
56
  case "assistant":
37
- return `[Assistant]\n${m.content}`;
57
+ return `[Assistant]\n${content}`;
38
58
  case "user":
39
59
  default:
40
- return `[User]\n${m.content}`;
60
+ return `[User]\n${content}`;
41
61
  }
42
62
  })
43
63
  .join("\n\n");
44
64
  }
45
65
 
66
+ function truncateContent(s: string): string {
67
+ if (s.length <= MAX_MSG_CHARS) return s;
68
+ return s.slice(0, MAX_MSG_CHARS) + `\n...[truncated ${s.length - MAX_MSG_CHARS} chars]`;
69
+ }
70
+
46
71
  // ──────────────────────────────────────────────────────────────────────────────
47
72
  // Core subprocess runner
48
73
  // ──────────────────────────────────────────────────────────────────────────────
@@ -53,23 +78,72 @@ export interface CliRunResult {
53
78
  exitCode: number;
54
79
  }
55
80
 
81
+ /**
82
+ * Build a minimal, safe environment for spawning CLI subprocesses.
83
+ *
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
88
+ * the CLI tools actually need keeps us well under the limit regardless of
89
+ * what the parent process environment contains.
90
+ */
91
+ 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
+ };
98
+
99
+ // Essential path/identity vars — always include when present.
100
+ for (const key of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP"]) {
101
+ const v = pick(key);
102
+ if (v) env[key] = v;
103
+ }
104
+
105
+ // Allow google-auth / claude auth paths to be inherited.
106
+ for (const key of [
107
+ "GOOGLE_APPLICATION_CREDENTIALS",
108
+ "ANTHROPIC_API_KEY",
109
+ "CLAUDE_API_KEY",
110
+ "CODEX_API_KEY",
111
+ "OPENAI_API_KEY",
112
+ "XDG_CONFIG_HOME",
113
+ "XDG_DATA_HOME",
114
+ "XDG_CACHE_HOME",
115
+ ]) {
116
+ const v = pick(key);
117
+ if (v) env[key] = v;
118
+ }
119
+
120
+ return env;
121
+ }
122
+
123
+ /**
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
+ */
56
128
  export function runCli(
57
129
  cmd: string,
58
130
  args: string[],
131
+ prompt: string,
59
132
  timeoutMs = 120_000
60
133
  ): Promise<CliRunResult> {
61
134
  return new Promise((resolve, reject) => {
62
135
  const proc = spawn(cmd, args, {
63
136
  timeout: timeoutMs,
64
- env: { ...process.env, NO_COLOR: "1" }, // strip ANSI codes from output
137
+ env: buildMinimalEnv(),
65
138
  });
66
139
 
67
140
  let stdout = "";
68
141
  let stderr = "";
69
142
 
70
- // Important: some CLIs (notably Claude Code) keep waiting for stdin EOF
71
- // even when prompt is provided as an argument. Close stdin immediately.
72
- proc.stdin.end();
143
+ // Write prompt to stdin then close prevents the CLI from waiting for more input.
144
+ proc.stdin.write(prompt, "utf8", () => {
145
+ proc.stdin.end();
146
+ });
73
147
 
74
148
  proc.stdout.on("data", (d: Buffer) => {
75
149
  stdout += d.toString();
@@ -102,16 +176,24 @@ export async function runGemini(
102
176
  timeoutMs: number
103
177
  ): Promise<string> {
104
178
  const model = stripPrefix(modelId);
105
- const args = ["-m", model, "-p", prompt];
106
- const result = await runCli("gemini", args, timeoutMs);
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);
107
186
 
108
- if (result.exitCode !== 0 && result.stdout.length === 0) {
109
- throw new Error(
110
- `gemini exited ${result.exitCode}: ${result.stderr || "(no output)"}`
111
- );
112
- }
187
+ if (result.exitCode !== 0 && result.stdout.length === 0) {
188
+ throw new Error(
189
+ `gemini exited ${result.exitCode}: ${result.stderr || "(no output)"}`
190
+ );
191
+ }
113
192
 
114
- return result.stdout || result.stderr; // gemini sometimes writes to stderr
193
+ return result.stdout || result.stderr;
194
+ } finally {
195
+ try { unlinkSync(tmpFile); } catch { /* ignore */ }
196
+ }
115
197
  }
116
198
 
117
199
  // ──────────────────────────────────────────────────────────────────────────────
@@ -128,6 +210,7 @@ export async function runClaude(
128
210
  timeoutMs: number
129
211
  ): Promise<string> {
130
212
  const model = stripPrefix(modelId);
213
+ // No prompt argument — deliver via stdin to avoid E2BIG
131
214
  const args = [
132
215
  "-p",
133
216
  "--output-format",
@@ -138,9 +221,8 @@ export async function runClaude(
138
221
  "",
139
222
  "--model",
140
223
  model,
141
- prompt,
142
224
  ];
143
- const result = await runCli("claude", args, timeoutMs);
225
+ const result = await runCli("claude", args, prompt, timeoutMs);
144
226
 
145
227
  if (result.exitCode !== 0 && result.stdout.length === 0) {
146
228
  throw new Error(
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatPrompt } from "../src/cli-runner.js";
3
+
4
+ describe("formatPrompt", () => {
5
+ it("returns empty string for empty messages", () => {
6
+ expect(formatPrompt([])).toBe("");
7
+ });
8
+
9
+ it("returns bare user text for a single short user message", () => {
10
+ const result = formatPrompt([{ role: "user", content: "hello" }]);
11
+ expect(result).toBe("hello");
12
+ });
13
+
14
+ it("truncates to MAX_MESSAGES (20) non-system messages", () => {
15
+ const messages = Array.from({ length: 30 }, (_, i) => ({
16
+ role: "user" as const,
17
+ content: `msg ${i}`,
18
+ }));
19
+ const result = formatPrompt(messages);
20
+ // Should contain last 20 messages, not first 10
21
+ expect(result).toContain("msg 29");
22
+ expect(result).not.toContain("msg 0\n");
23
+ // Single-turn mode doesn't apply when there are multiple messages
24
+ expect(result).toContain("[User]");
25
+ });
26
+
27
+ it("keeps system message + last 20 non-system messages", () => {
28
+ const sys = { role: "system" as const, content: "You are helpful" };
29
+ const msgs = Array.from({ length: 25 }, (_, i) => ({
30
+ role: "user" as const,
31
+ content: `msg ${i}`,
32
+ }));
33
+ const result = formatPrompt([sys, ...msgs]);
34
+ expect(result).toContain("[System]");
35
+ expect(result).toContain("You are helpful");
36
+ expect(result).toContain("msg 24"); // last
37
+ expect(result).not.toContain("msg 0\n"); // first (truncated)
38
+ });
39
+
40
+ it("truncates individual message content at MAX_MSG_CHARS (4000)", () => {
41
+ const longContent = "x".repeat(5000);
42
+ const result = formatPrompt([{ role: "user", content: longContent }]);
43
+ expect(result.length).toBeLessThan(5000);
44
+ expect(result).toContain("truncated");
45
+ });
46
+ });