ocb-cli 1.0.0

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/dist/proxy.js ADDED
@@ -0,0 +1,504 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "url";
3
+ import { dirname } from "path";
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+ const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || "http://127.0.0.1:4096";
7
+ const OPENCODE_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || "";
8
+ const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8300");
9
+ let currentSessionId = null;
10
+ let totalTokensUsed = 0;
11
+ let totalRequests = 0;
12
+ let currentModel = "minimax-m2.5-free";
13
+ let availableModels = [];
14
+ let providers = {};
15
+ async function fetchModelsFromOpenCode() {
16
+ try {
17
+ const response = await fetch(`${OPENCODE_SERVER_URL}/provider`);
18
+ const data = await response.json();
19
+ if (data.all) {
20
+ providers = {};
21
+ availableModels = [];
22
+ for (const [providerID, providerData] of Object.entries(data.all)) {
23
+ const p = providerData;
24
+ providers[providerID] = { name: p.name, source: p.source };
25
+ if (p.models) {
26
+ for (const [modelID, modelData] of Object.entries(p.models)) {
27
+ const m = modelData;
28
+ availableModels.push({
29
+ id: `${providerID}/${modelID}`,
30
+ name: m.name || modelID,
31
+ provider: p.name,
32
+ providerID,
33
+ cost: m.cost
34
+ });
35
+ }
36
+ }
37
+ }
38
+ availableModels.sort((a, b) => {
39
+ if (a.provider !== b.provider)
40
+ return a.provider.localeCompare(b.provider);
41
+ return a.name.localeCompare(b.name);
42
+ });
43
+ console.error(`Loaded ${availableModels.length} models from ${Object.keys(providers).length} providers`);
44
+ }
45
+ }
46
+ catch (e) {
47
+ console.error("Failed to fetch models:", e);
48
+ }
49
+ }
50
+ fetchModelsFromOpenCode();
51
+ async function createSession(workspace) {
52
+ const response = await fetch(`${OPENCODE_SERVER_URL}/session`, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ ...(OPENCODE_PASSWORD ? { "Authorization": `Basic ${Buffer.from(`opencode:${OPENCODE_PASSWORD}`).toString("base64")}` } : {})
57
+ },
58
+ body: JSON.stringify({ workspace, mode: "agent" })
59
+ });
60
+ const data = await response.json();
61
+ return data.id;
62
+ }
63
+ function extractTextFromContent(content) {
64
+ if (typeof content === "string")
65
+ return content;
66
+ if (Array.isArray(content)) {
67
+ return content.map(c => typeof c === "string" ? c : c?.text || "").join("");
68
+ }
69
+ return "";
70
+ }
71
+ async function sendMessage(sessionId, messages) {
72
+ const reversed = [...messages].reverse();
73
+ let lastUserMessage = null;
74
+ for (const m of reversed) {
75
+ if (m.role === "user") {
76
+ const content = extractTextFromContent(m.content);
77
+ if (content && content.length > 2 && content !== "count") {
78
+ lastUserMessage = m;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ if (!lastUserMessage)
84
+ return { text: "OK", tokens: 0 };
85
+ const combinedContent = extractTextFromContent(lastUserMessage.content);
86
+ const response = await fetch(`${OPENCODE_SERVER_URL}/session/${sessionId}/message`, {
87
+ method: "POST",
88
+ headers: {
89
+ "Content-Type": "application/json",
90
+ ...(OPENCODE_PASSWORD ? { "Authorization": `Basic ${Buffer.from(`opencode:${OPENCODE_PASSWORD}`).toString("base64")}` } : {})
91
+ },
92
+ body: JSON.stringify({ parts: [{ type: "text", text: combinedContent }] })
93
+ });
94
+ const data = await response.json();
95
+ let fullResponse = "";
96
+ let tokens = 0;
97
+ if (data.parts && Array.isArray(data.parts)) {
98
+ for (const part of data.parts) {
99
+ if (part.type === "text")
100
+ fullResponse += part.text;
101
+ }
102
+ }
103
+ if (data.info?.tokens)
104
+ tokens = data.info.tokens.total || 0;
105
+ return { text: fullResponse, tokens };
106
+ }
107
+ const app = express();
108
+ app.use(express.json());
109
+ app.use((req, res, next) => {
110
+ res.header("Access-Control-Allow-Origin", "*");
111
+ res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
112
+ res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
113
+ if (req.method === "OPTIONS")
114
+ return res.sendStatus(200);
115
+ next();
116
+ });
117
+ app.get("/", (req, res) => res.send(generateHTML()));
118
+ app.get("/api/status", (req, res) => {
119
+ res.json({
120
+ proxyPort: PROXY_PORT,
121
+ opencodePort: 4096,
122
+ currentModel,
123
+ totalRequests,
124
+ totalTokensUsed,
125
+ sessionId: currentSessionId ? "active" : "inactive"
126
+ });
127
+ });
128
+ app.get("/api/models", (req, res) => {
129
+ const grouped = {};
130
+ for (const model of availableModels) {
131
+ if (!grouped[model.provider])
132
+ grouped[model.provider] = [];
133
+ grouped[model.provider].push(model);
134
+ }
135
+ res.json({
136
+ models: availableModels,
137
+ grouped,
138
+ providers: Object.entries(providers).map(([id, p]) => ({ id, ...p }))
139
+ });
140
+ });
141
+ app.post("/api/model", (req, res) => {
142
+ const { modelId } = req.body;
143
+ const model = availableModels.find(m => m.id === modelId);
144
+ if (model) {
145
+ currentModel = modelId;
146
+ currentSessionId = null;
147
+ res.json({ success: true, model });
148
+ }
149
+ else {
150
+ res.status(400).json({ success: false, error: "Model not found" });
151
+ }
152
+ });
153
+ app.post("/api/refresh-models", async (req, res) => {
154
+ await fetchModelsFromOpenCode();
155
+ res.json({ success: true, count: availableModels.length });
156
+ });
157
+ app.post("/api/reset-stats", (req, res) => {
158
+ totalTokensUsed = 0;
159
+ totalRequests = 0;
160
+ res.json({ success: true });
161
+ });
162
+ app.post("/api/reset-session", async (req, res) => {
163
+ currentSessionId = await createSession(process.cwd());
164
+ res.json({ success: true, sessionId: currentSessionId });
165
+ });
166
+ app.get("/v1/authenticate", (req, res) => res.json({ type: "authentication", authenticated: true }));
167
+ app.get("/v1/whoami", (req, res) => res.json({ type: "user", id: "opencode-user", email: "opencode@local" }));
168
+ app.get("/v1/models", (req, res) => {
169
+ res.json({ data: availableModels.map(m => ({
170
+ id: m.id, type: "model", name: m.name,
171
+ supports_cached_previews: true, supports_system_instructions: true
172
+ })) });
173
+ });
174
+ app.get("/v1/models/list", (req, res) => {
175
+ res.json({ data: availableModels.map(m => ({
176
+ id: m.id, type: "model", name: m.name,
177
+ supports_cached_previews: true, supports_system_instructions: true
178
+ })) });
179
+ });
180
+ app.post("/v1/messages", async (req, res) => {
181
+ try {
182
+ if (req.body?.max_tokens === undefined && req.body?.messages) {
183
+ const messages = req.body.messages;
184
+ let totalTokens = 0;
185
+ for (const msg of messages) {
186
+ const content = extractTextFromContent(msg.content);
187
+ totalTokens += Math.ceil(content.length / 4);
188
+ }
189
+ return res.json({ tokens: totalTokens });
190
+ }
191
+ const { messages, model } = req.body;
192
+ if (!currentSessionId) {
193
+ currentSessionId = await createSession(process.cwd());
194
+ }
195
+ const { text, tokens } = await sendMessage(currentSessionId, messages);
196
+ totalRequests++;
197
+ totalTokensUsed += tokens;
198
+ res.json({
199
+ id: `msg_${Date.now()}`,
200
+ type: "message",
201
+ role: "assistant",
202
+ content: [{ type: "text", text }],
203
+ model: currentModel,
204
+ stop_reason: "end_turn",
205
+ usage: {
206
+ input_tokens: Math.ceil((JSON.stringify(messages).length) / 4),
207
+ output_tokens: Math.ceil(text.length / 4)
208
+ }
209
+ });
210
+ }
211
+ catch (error) {
212
+ console.error("Error:", error);
213
+ res.status(500).json({ error: { type: "api_error", message: error instanceof Error ? error.message : String(error) } });
214
+ }
215
+ });
216
+ app.post("/v1/messages/count_tokens", (req, res) => {
217
+ const { messages } = req.body;
218
+ let totalTokens = 0;
219
+ for (const msg of messages || []) {
220
+ const content = extractTextFromContent(msg.content);
221
+ totalTokens += Math.ceil(content.length / 4);
222
+ }
223
+ res.json({ tokens: totalTokens });
224
+ });
225
+ function generateHTML() {
226
+ return `<!DOCTYPE html>
227
+ <html lang="en">
228
+ <head>
229
+ <meta charset="UTF-8">
230
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
231
+ <title>OpenCode Bridge</title>
232
+ <script src="https://cdn.tailwindcss.com"></script>
233
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
234
+ <style>
235
+ * { box-sizing: border-box; margin: 0; padding: 0; }
236
+ body { font-family: 'Inter', -apple-system, sans-serif; background: #0a0a0f; color: #e4e4e7; min-height: 100vh; }
237
+
238
+ .app-container { display: flex; height: 100vh; }
239
+
240
+ /* Sidebar */
241
+ .sidebar { width: 280px; background: #121218; border-right: 1px solid #27272a; display: flex; flex-direction: column; }
242
+ .sidebar-header { padding: 20px; border-bottom: 1px solid #27272a; }
243
+ .logo { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
244
+ .logo-icon { width: 28px; height: 28px; background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 6px; display: flex; align-items: center; justify-content: center; }
245
+
246
+ /* Search */
247
+ .search-box { padding: 16px 20px; }
248
+ .search-input { width: 100%; padding: 10px 12px; background: #1a1a20; border: 1px solid #27272a; border-radius: 8px; color: #e4e4e7; font-size: 13px; outline: none; transition: border-color 0.2s; }
249
+ .search-input:focus { border-color: #6366f1; }
250
+ .search-input::placeholder { color: #71717a; }
251
+
252
+ /* Provider List */
253
+ .provider-list { flex: 1; overflow-y: auto; padding: 8px; }
254
+ .provider-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 13px; transition: background 0.15s; }
255
+ .provider-item:hover { background: #1f1f26; }
256
+ .provider-item.active { background: #6366f1/15; color: #a5b4fc; }
257
+ .provider-name { display: flex; align-items: center; gap: 8px; }
258
+ .provider-count { font-size: 11px; color: #71717a; background: #27272a; padding: 2px 6px; border-radius: 4px; }
259
+
260
+ /* Main Content */
261
+ .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
262
+
263
+ /* Header */
264
+ .main-header { padding: 16px 24px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; background: #121218; }
265
+ .header-left { display: flex; align-items: center; gap: 16px; }
266
+ .current-model-badge { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #6366f1/15; border: 1px solid #6366f1/30; border-radius: 8px; font-size: 13px; }
267
+ .status-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; }
268
+ .status-dot.inactive { background: #ef4444; }
269
+
270
+ /* Model List */
271
+ .model-list-container { flex: 1; overflow-y: auto; padding: 16px 24px; }
272
+ .model-list-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; margin-bottom: 12px; padding: 0 8px; }
273
+ .model-item { padding: 12px 16px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; transition: all 0.15s; border: 1px solid transparent; margin-bottom: 4px; }
274
+ .model-item:hover { background: #1f1f26; }
275
+ .model-item.selected { background: #6366f1/15; border-color: #6366f1/40; }
276
+ .model-info { display: flex; flex-direction: column; gap: 2px; }
277
+ .model-name { font-size: 14px; font-weight: 500; }
278
+ .model-id { font-size: 11px; color: #71717a; font-family: 'JetBrains Mono', monospace; }
279
+ .model-cost { font-size: 11px; color: #a1a1aa; text-align: right; }
280
+ .model-cost span { display: block; }
281
+
282
+ /* Footer Stats */
283
+ .footer { padding: 12px 24px; border-top: 1px solid #27272a; display: flex; gap: 24px; background: #121218; }
284
+ .stat-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #a1a1aa; }
285
+ .stat-value { color: #e4e4e7; font-weight: 500; }
286
+
287
+ /* Scrollbar */
288
+ ::-webkit-scrollbar { width: 6px; }
289
+ ::-webkit-scrollbar-track { background: transparent; }
290
+ ::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
291
+ ::-webkit-scrollbar-thumb:hover { background: #52525b; }
292
+
293
+ /* Buttons */
294
+ .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; border: none; }
295
+ .btn-primary { background: #6366f1; color: white; }
296
+ .btn-primary:hover { background: #4f46e5; }
297
+ .btn-secondary { background: #27272a; color: #a1a1aa; }
298
+ .btn-secondary:hover { background: #3f3f46; color: #e4e4e7; }
299
+ </style>
300
+ </head>
301
+ <body>
302
+ <div class="app-container">
303
+ <!-- Sidebar -->
304
+ <div class="sidebar">
305
+ <div class="sidebar-header">
306
+ <div class="logo">
307
+ <div class="logo-icon">⚡</div>
308
+ <span>OpenCode Bridge</span>
309
+ </div>
310
+ </div>
311
+
312
+ <div class="search-box">
313
+ <input type="text" class="search-input" placeholder="Search models..." id="searchInput" oninput="filterModels()">
314
+ </div>
315
+
316
+ <div class="provider-list" id="providerList">
317
+ <!-- Providers loaded here -->
318
+ </div>
319
+ </div>
320
+
321
+ <!-- Main Content -->
322
+ <div class="main-content">
323
+ <div class="main-header">
324
+ <div class="header-left">
325
+ <div class="current-model-badge">
326
+ <div class="status-dot" id="statusDot"></div>
327
+ <span id="currentModelName">Loading...</span>
328
+ </div>
329
+ </div>
330
+ <div style="display: flex; gap: 8px;">
331
+ <button class="btn btn-secondary" onclick="resetSession()">↻ Session</button>
332
+ <button class="btn btn-secondary" onclick="resetStats()">📊 Reset</button>
333
+ </div>
334
+ </div>
335
+
336
+ <div class="model-list-container">
337
+ <div class="model-list-header" id="modelListHeader">All Models</div>
338
+ <div id="modelList">
339
+ <!-- Models loaded here -->
340
+ </div>
341
+ </div>
342
+
343
+ <div class="footer">
344
+ <div class="stat-item">
345
+ <span>Requests:</span>
346
+ <span class="stat-value" id="totalRequests">0</span>
347
+ </div>
348
+ <div class="stat-item">
349
+ <span>Tokens:</span>
350
+ <span class="stat-value" id="totalTokens">0</span>
351
+ </div>
352
+ <div class="stat-item">
353
+ <span>Models:</span>
354
+ <span class="stat-value" id="totalModels">0</span>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ </div>
359
+
360
+ <script>
361
+ const PROXY_PORT = ${PROXY_PORT};
362
+ let allModels = [];
363
+ let groupedModels = {};
364
+ let currentProvider = 'all';
365
+ let currentModelId = null;
366
+
367
+ async function loadModels() {
368
+ const res = await fetch('/api/models');
369
+ const data = await res.json();
370
+
371
+ allModels = data.models || [];
372
+ groupedModels = data.grouped || {};
373
+
374
+ renderProviders();
375
+ renderModels(allModels);
376
+ updateStats();
377
+ }
378
+
379
+ function renderProviders() {
380
+ const container = document.getElementById('providerList');
381
+ const providers = Object.entries(groupedModels);
382
+
383
+ let html = \`<div class="provider-item \${currentProvider === 'all' ? 'active' : ''}" onclick="selectProvider('all')">
384
+ <span class="provider-name">🏠 All Models</span>
385
+ <span class="provider-count">\${allModels.length}</span>
386
+ </div>\`;
387
+
388
+ for (const [provider, models] of providers) {
389
+ html += \`<div class="provider-item \${currentProvider === provider ? 'active' : ''}" onclick="selectProvider('\${provider}')">
390
+ <span class="provider-name">📦 \${provider}</span>
391
+ <span class="provider-count">\${models.length}</span>
392
+ </div>\`;
393
+ }
394
+
395
+ container.innerHTML = html;
396
+ }
397
+
398
+ function selectProvider(provider) {
399
+ currentProvider = provider;
400
+ renderProviders();
401
+
402
+ const models = provider === 'all' ? allModels : groupedModels[provider] || [];
403
+ document.getElementById('modelListHeader').textContent = provider === 'all' ? 'All Models' : provider;
404
+ renderModels(models);
405
+ }
406
+
407
+ function renderModels(models) {
408
+ const container = document.getElementById('modelList');
409
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
410
+
411
+ let filtered = models;
412
+ if (searchTerm) {
413
+ filtered = models.filter(m =>
414
+ m.name.toLowerCase().includes(searchTerm) ||
415
+ m.id.toLowerCase().includes(searchTerm) ||
416
+ m.provider.toLowerCase().includes(searchTerm)
417
+ );
418
+ }
419
+
420
+ if (filtered.length === 0) {
421
+ container.innerHTML = '<div style="text-align: center; color: #71717a; padding: 40px;">No models found</div>';
422
+ return;
423
+ }
424
+
425
+ let html = '';
426
+ for (const model of filtered) {
427
+ const isSelected = model.id === currentModelId;
428
+ const costInfo = model.cost ? \`<span>$\${model.cost.input}/M in</span><span>$\${model.cost.output}/M out</span>\` : '';
429
+
430
+ html += \`<div class="model-item \${isSelected ? 'selected' : ''}" onclick="selectModel('\${model.id}')">
431
+ <div class="model-info">
432
+ <div class="model-name">\${model.name}</div>
433
+ <div class="model-id">\${model.id}</div>
434
+ </div>
435
+ <div class="model-cost">\${costInfo}</div>
436
+ </div>\`;
437
+ }
438
+
439
+ container.innerHTML = html;
440
+ }
441
+
442
+ function filterModels() {
443
+ const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
444
+ renderModels(models);
445
+ }
446
+
447
+ async function selectModel(modelId) {
448
+ const res = await fetch('/api/model', {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({ modelId })
452
+ });
453
+ const data = await res.json();
454
+ if (data.success) {
455
+ currentModelId = modelId;
456
+ loadStatus();
457
+ const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
458
+ renderModels(models);
459
+ }
460
+ }
461
+
462
+ async function loadStatus() {
463
+ const res = await fetch('/api/status');
464
+ const data = await res.json();
465
+
466
+ const model = allModels.find(m => m.id === data.currentModel);
467
+ document.getElementById('currentModelName').textContent = model ? model.name : data.currentModel;
468
+
469
+ const statusDot = document.getElementById('statusDot');
470
+ if (data.sessionId === 'active') {
471
+ statusDot.classList.remove('inactive');
472
+ } else {
473
+ statusDot.classList.add('inactive');
474
+ }
475
+
476
+ document.getElementById('totalRequests').textContent = data.totalRequests.toLocaleString();
477
+ document.getElementById('totalTokens').textContent = data.totalTokensUsed.toLocaleString();
478
+ document.getElementById('totalModels').textContent = allModels.length.toLocaleString();
479
+
480
+ currentModelId = data.currentModel;
481
+ }
482
+
483
+ async function resetSession() {
484
+ await fetch('/api/reset-session', { method: 'POST' });
485
+ loadStatus();
486
+ }
487
+
488
+ async function resetStats() {
489
+ await fetch('/api/reset-stats', { method: 'POST' });
490
+ loadStatus();
491
+ }
492
+
493
+ // Initialize
494
+ loadModels();
495
+ loadStatus();
496
+ setInterval(loadStatus, 3000);
497
+ </script>
498
+ </body>
499
+ </html>`;
500
+ }
501
+ app.listen(PROXY_PORT, () => {
502
+ console.error(`OpenCode Bridge running on http://localhost:${PROXY_PORT}`);
503
+ console.error(`Dashboard: http://localhost:${PROXY_PORT}`);
504
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "ocb-cli",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode Bridge - Use OpenCode AI models in Claude Code",
5
+ "type": "module",
6
+ "main": "dist/proxy.js",
7
+ "bin": {
8
+ "ocb": "dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/proxy.js",
13
+ "stop": "taskkill /F /IM node.exe 2>nul || true",
14
+ "cli": "node dist/cli.js",
15
+ "dev": "tsx src/proxy.ts"
16
+ },
17
+ "dependencies": {
18
+ "express": "^4.18.0",
19
+ "tsx": "^4.0.0",
20
+ "zod": "^3.23.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/express": "^4.17.0",
24
+ "@types/node": "^20.0.0",
25
+ "typescript": "^5.0.0"
26
+ },
27
+ "keywords": ["opencode", "claude-code", "bridge", "ai", "cli", "ocb"],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "package.json",
34
+ "tsconfig.json",
35
+ "README.md"
36
+ ]
37
+ }