@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 +7 -0
- package/SKILL.md +24 -0
- package/openclaw.plugin.json +4 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +105 -23
- package/test/cli-runner.test.ts +46 -0
package/.clawhubignore
ADDED
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.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
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": [
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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${
|
|
55
|
+
return `[System]\n${content}`;
|
|
36
56
|
case "assistant":
|
|
37
|
-
return `[Assistant]\n${
|
|
57
|
+
return `[Assistant]\n${content}`;
|
|
38
58
|
case "user":
|
|
39
59
|
default:
|
|
40
|
-
return `[User]\n${
|
|
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:
|
|
137
|
+
env: buildMinimalEnv(),
|
|
65
138
|
});
|
|
66
139
|
|
|
67
140
|
let stdout = "";
|
|
68
141
|
let stderr = "";
|
|
69
142
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
106
|
-
const
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
+
});
|