arbiter-cli 0.1.4 → 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.
Files changed (4) hide show
  1. package/chat.mjs +474 -0
  2. package/index.js +1 -135
  3. package/index.mjs +146 -0
  4. package/package.json +8 -4
package/chat.mjs ADDED
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Arbiter Chat CLI — Premium edition.
4
+ * Smart routing, markdown rendering, persistent history, multi-line input, and more.
5
+ * Pure Node.js, zero npm dependencies.
6
+ */
7
+ import { createInterface } from 'readline'
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'
9
+ import { resolve, join } from 'path'
10
+ import { homedir, platform } from 'os'
11
+ import { execSync } from 'child_process'
12
+
13
+ const PROXY_URL = 'https://arbiter-proxy-long-summit-6283.fly.dev/v1/chat/completions'
14
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
15
+ const ARBITER_DIR = join(homedir(), '.arbiter')
16
+ const CONVOS_DIR = join(ARBITER_DIR, 'conversations')
17
+ const HISTORY_FILE = join(ARBITER_DIR, 'history')
18
+ const MODEL_MAP = { claude: 'anthropic/claude-sonnet-4', gpt4o: 'openai/gpt-4o', flash: 'google/gemini-2.5-flash', auto: null }
19
+
20
+ const c = {
21
+ green: s => `\x1b[32m${s}\x1b[0m`, cyan: s => `\x1b[36m${s}\x1b[0m`,
22
+ dim: s => `\x1b[2m${s}\x1b[0m`, bold: s => `\x1b[1m${s}\x1b[0m`,
23
+ yellow: s => `\x1b[33m${s}\x1b[0m`, red: s => `\x1b[31m${s}\x1b[0m`,
24
+ bgCyan: s => `\x1b[46m\x1b[30m${s}\x1b[0m`,
25
+ }
26
+
27
+ // --- Ensure directories ---
28
+ if (!existsSync(ARBITER_DIR)) mkdirSync(ARBITER_DIR, { recursive: true })
29
+ if (!existsSync(CONVOS_DIR)) mkdirSync(CONVOS_DIR, { recursive: true })
30
+
31
+ // --- Parse CLI flags ---
32
+ const argv = process.argv.slice(2)
33
+ let systemPrompt = null, cliPrefix = null
34
+ for (let i = 0; i < argv.length; i++) {
35
+ if (argv[i] === '--system' && argv[i + 1]) { systemPrompt = argv[++i]; continue }
36
+ if (!argv[i].startsWith('-') && !cliPrefix) { cliPrefix = argv[i] }
37
+ }
38
+
39
+ // --- Load API key ---
40
+ function loadApiKey() {
41
+ if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY
42
+ const paths = [resolve(process.cwd(), '.env'), resolve(import.meta.dirname || '.', '..', '.env')]
43
+ for (const p of paths) {
44
+ if (existsSync(p)) {
45
+ const m = readFileSync(p, 'utf-8').match(/^OPENROUTER_API_KEY=(.+)$/m)
46
+ if (m) return m[1].trim()
47
+ }
48
+ }
49
+ return null
50
+ }
51
+
52
+ const API_KEY = loadApiKey()
53
+ if (!API_KEY) {
54
+ console.error(c.yellow('\n ✗ No API key found.'))
55
+ console.error(c.dim(' Set OPENROUTER_API_KEY in .env or as an environment variable.\n'))
56
+ process.exit(1)
57
+ }
58
+
59
+ // --- State ---
60
+ let messages = []
61
+ let modelOverride = null
62
+ let lastAssistantContent = ''
63
+ const session = { requests: 0, totalSaved: 0, totalCost: 0, totalBaseline: 0, totalTokens: 0 }
64
+
65
+ // --- Spinner ---
66
+ function createSpinner() {
67
+ let i = 0, timer = null
68
+ return {
69
+ start() {
70
+ process.stdout.write(` ${c.dim(SPINNER_FRAMES[0] + ' thinking...')}`)
71
+ timer = setInterval(() => {
72
+ i = (i + 1) % SPINNER_FRAMES.length
73
+ process.stdout.write(`\r ${c.dim(SPINNER_FRAMES[i] + ' thinking...')}`)
74
+ }, 80)
75
+ },
76
+ stop() { if (timer) { clearInterval(timer); timer = null }; process.stdout.write('\r\x1b[K') },
77
+ }
78
+ }
79
+
80
+ // --- Markdown renderer (streaming, line-buffered) ---
81
+ function renderInline(text) {
82
+ let out = '', i = 0
83
+ while (i < text.length) {
84
+ if (text[i] === '`' && text[i + 1] !== '`') {
85
+ const end = text.indexOf('`', i + 1)
86
+ if (end !== -1) { out += c.cyan(text.slice(i + 1, end)); i = end + 1; continue }
87
+ }
88
+ if (text[i] === '*' && text[i + 1] === '*') {
89
+ const end = text.indexOf('**', i + 2)
90
+ if (end !== -1) { out += c.bold(text.slice(i + 2, end)); i = end + 2; continue }
91
+ }
92
+ out += text[i]; i++
93
+ }
94
+ return out
95
+ }
96
+
97
+ function createRenderer() {
98
+ let inCodeBlock = false, lineBuffer = ''
99
+ return {
100
+ write(chunk) {
101
+ for (const ch of chunk) {
102
+ if (ch === '\n') { this._flushLine(); lineBuffer = '' } else { lineBuffer += ch }
103
+ }
104
+ },
105
+ _flushLine() {
106
+ const line = lineBuffer
107
+ if (line.startsWith('```') && !inCodeBlock) {
108
+ inCodeBlock = true
109
+ const lang = line.slice(3).trim()
110
+ process.stdout.write(` ${c.dim('┌' + (lang ? ' ' + lang + ' ' : '─'))}\n`)
111
+ } else if (line.startsWith('```') && inCodeBlock) {
112
+ inCodeBlock = false
113
+ process.stdout.write(` ${c.dim('└─')}\n`)
114
+ } else if (inCodeBlock) {
115
+ process.stdout.write(` ${c.dim('│')} ${line}\n`)
116
+ } else if (line.match(/^>{1}\s?/)) {
117
+ process.stdout.write(` ${c.dim('│')} ${c.dim(renderInline(line.replace(/^>\s?/, '')))}\n`)
118
+ } else if (line.match(/^#{1,3}\s+/)) {
119
+ const h = line.match(/^#{1,3}\s+(.+)/)
120
+ process.stdout.write(`\n ${c.bold(h[1])}\n`)
121
+ } else if (line.match(/^[-*]\s/)) {
122
+ process.stdout.write(` • ${renderInline(line.slice(2))}\n`)
123
+ } else if (line.match(/^\d+\.\s/)) {
124
+ process.stdout.write(` ${renderInline(line)}\n`)
125
+ } else {
126
+ process.stdout.write(` ${renderInline(line)}\n`)
127
+ }
128
+ },
129
+ end() {
130
+ if (!lineBuffer) return
131
+ if (inCodeBlock) process.stdout.write(` ${c.dim('│')} ${lineBuffer}`)
132
+ else process.stdout.write(` ${renderInline(lineBuffer)}`)
133
+ lineBuffer = ''
134
+ },
135
+ }
136
+ }
137
+
138
+ // --- History management ---
139
+ function loadHistory() {
140
+ if (!existsSync(HISTORY_FILE)) return []
141
+ const lines = readFileSync(HISTORY_FILE, 'utf-8').split('\n').filter(Boolean)
142
+ return lines.slice(-500)
143
+ }
144
+
145
+ function saveHistoryLine(line) {
146
+ try {
147
+ const existing = existsSync(HISTORY_FILE) ? readFileSync(HISTORY_FILE, 'utf-8') : ''
148
+ writeFileSync(HISTORY_FILE, existing + line + '\n')
149
+ } catch {}
150
+ }
151
+
152
+ // --- Clipboard ---
153
+ function copyToClipboard(text) {
154
+ try {
155
+ const plat = platform()
156
+ const cmd = plat === 'darwin' ? 'pbcopy' : plat === 'win32' ? 'clip' : 'xclip -selection clipboard'
157
+ execSync(cmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] })
158
+ return true
159
+ } catch { return false }
160
+ }
161
+
162
+ // --- Conversation save/load ---
163
+ function saveConversation(name) {
164
+ const file = join(CONVOS_DIR, `${name}.json`)
165
+ writeFileSync(file, JSON.stringify(messages, null, 2))
166
+ console.log(c.dim(` ✓ Saved to ~/.arbiter/conversations/${name}.json`))
167
+ }
168
+
169
+ function loadConversation(name) {
170
+ const file = join(CONVOS_DIR, `${name}.json`)
171
+ if (!existsSync(file)) { console.log(c.yellow(` ✗ Conversation "${name}" not found.`)); return }
172
+ messages = JSON.parse(readFileSync(file, 'utf-8'))
173
+ console.log(c.dim(` ✓ Loaded "${name}" (${messages.length} messages)`))
174
+ }
175
+
176
+ function listConversations() {
177
+ if (!existsSync(CONVOS_DIR)) { console.log(c.dim(' No saved conversations.')); return }
178
+ const files = readdirSync(CONVOS_DIR).filter(f => f.endsWith('.json'))
179
+ if (!files.length) { console.log(c.dim(' No saved conversations.')); return }
180
+ console.log(`\n ${c.bold('Saved conversations:')}`)
181
+ for (const f of files) console.log(` ${c.cyan(f.replace('.json', ''))}`)
182
+ console.log('')
183
+ }
184
+
185
+ // --- Prompt builder ---
186
+ function getPrompt() {
187
+ const parts = []
188
+ if (modelOverride) parts.push(c.dim(`[${Object.entries(MODEL_MAP).find(([, v]) => v === modelOverride)?.[0] || 'custom'}]`))
189
+ if (session.totalCost > 0) {
190
+ const costStr = session.totalCost < 0.001 ? '<$0.001' : '$' + session.totalCost.toFixed(2)
191
+ parts.push(c.dim(`[${costStr}]`))
192
+ }
193
+ parts.push(c.bold('›'))
194
+ return ' ' + parts.join(' ') + ' '
195
+ }
196
+
197
+ // --- Pipe mode: stdin is not a TTY ---
198
+ if (!process.stdin.isTTY) {
199
+ const chunks = []
200
+ process.stdin.on('data', d => chunks.push(d))
201
+ process.stdin.on('end', async () => {
202
+ const piped = Buffer.concat(chunks).toString().trim()
203
+ const content = cliPrefix ? `${cliPrefix}\n\n${piped}` : piped
204
+ messages.push({ role: 'user', content })
205
+ if (systemPrompt) messages.unshift({ role: 'system', content: systemPrompt })
206
+ await streamResponse(true)
207
+ process.exit(0)
208
+ })
209
+ } else {
210
+ // --- Interactive TTY mode ---
211
+ const history = loadHistory()
212
+
213
+ console.log(`\n${c.bold(' ⚡ Arbiter Chat')}`)
214
+ console.log(c.dim(' Smart routing · 69% average savings'))
215
+ if (systemPrompt) console.log(c.dim(` System: ${systemPrompt}`))
216
+ console.log(c.dim(' Type /stats, /model, /save, /load, /copy, /quit'))
217
+ console.log(c.dim(' Use """ for multi-line input, Ctrl+R for history search\n'))
218
+
219
+ const rl = createInterface({
220
+ input: process.stdin,
221
+ output: process.stdout,
222
+ prompt: getPrompt(),
223
+ history,
224
+ historySize: 500,
225
+ })
226
+
227
+ let multiLineMode = false, multiLineBuffer = [], searchMode = false, searchQuery = ''
228
+
229
+ rl.prompt()
230
+
231
+ rl.on('line', async (input) => {
232
+ const line = input.trim()
233
+
234
+ // Multi-line mode handling
235
+ if (multiLineMode) {
236
+ if (line === '"""') {
237
+ multiLineMode = false
238
+ const content = multiLineBuffer.join('\n')
239
+ multiLineBuffer = []
240
+ if (content.trim()) {
241
+ saveHistoryLine(content.replace(/\n/g, '\\n'))
242
+ messages.push({ role: 'user', content })
243
+ if (systemPrompt && !messages.find(m => m.role === 'system'))
244
+ messages.unshift({ role: 'system', content: systemPrompt })
245
+ await streamResponse()
246
+ }
247
+ rl.setPrompt(getPrompt()); rl.prompt()
248
+ } else {
249
+ multiLineBuffer.push(input)
250
+ process.stdout.write(' … ')
251
+ }
252
+ return
253
+ }
254
+
255
+ if (line === '"""') {
256
+ multiLineMode = true; multiLineBuffer = []
257
+ process.stdout.write(' … ')
258
+ return
259
+ }
260
+
261
+ if (!line) { rl.prompt(); return }
262
+
263
+ // Commands
264
+ if (line === '/quit' || line === '/exit') { goodbye(); return }
265
+ if (line === '/stats') { showStats(); rl.setPrompt(getPrompt()); rl.prompt(); return }
266
+ if (line === '/conversations' || line === '/convos') { listConversations(); rl.setPrompt(getPrompt()); rl.prompt(); return }
267
+ if (line === '/copy') {
268
+ if (!lastAssistantContent) { console.log(c.dim(' No response to copy.')); }
269
+ else if (copyToClipboard(lastAssistantContent)) { console.log(c.green(' ✓ Copied to clipboard')); }
270
+ else { console.log(c.yellow(' ✗ Could not copy (clipboard tool not available)')); }
271
+ rl.setPrompt(getPrompt()); rl.prompt(); return
272
+ }
273
+ if (line.startsWith('/model')) {
274
+ const arg = line.split(/\s+/)[1]
275
+ if (!arg || !MODEL_MAP.hasOwnProperty(arg)) {
276
+ console.log(c.dim(` Available models: ${Object.keys(MODEL_MAP).join(', ')}`))
277
+ console.log(c.dim(` Current: ${modelOverride ? Object.entries(MODEL_MAP).find(([, v]) => v === modelOverride)?.[0] || modelOverride : 'auto'}`))
278
+ } else {
279
+ modelOverride = MODEL_MAP[arg]
280
+ console.log(modelOverride ? c.dim(` Model override: ${c.cyan(arg)} → ${modelOverride}`) : c.dim(' Model: auto (router decides)'))
281
+ }
282
+ rl.setPrompt(getPrompt()); rl.prompt(); return
283
+ }
284
+ if (line.startsWith('/save')) {
285
+ const name = line.split(/\s+/)[1]
286
+ if (!name) { console.log(c.dim(' Usage: /save <name>')); }
287
+ else { saveConversation(name) }
288
+ rl.setPrompt(getPrompt()); rl.prompt(); return
289
+ }
290
+ if (line.startsWith('/load')) {
291
+ const name = line.split(/\s+/)[1]
292
+ if (!name) { console.log(c.dim(' Usage: /load <name>')); }
293
+ else { loadConversation(name) }
294
+ rl.setPrompt(getPrompt()); rl.prompt(); return
295
+ }
296
+ if (line.startsWith('/')) { console.log(c.dim(` Unknown command: ${line}`)); rl.setPrompt(getPrompt()); rl.prompt(); return }
297
+
298
+ // Regular message
299
+ saveHistoryLine(line)
300
+ messages.push({ role: 'user', content: line })
301
+ if (systemPrompt && !messages.find(m => m.role === 'system'))
302
+ messages.unshift({ role: 'system', content: systemPrompt })
303
+ await streamResponse()
304
+ rl.setPrompt(getPrompt()); rl.prompt()
305
+ })
306
+
307
+ rl.on('close', goodbye)
308
+ process.on('SIGINT', goodbye)
309
+
310
+ // --- Ctrl+R reverse search ---
311
+ process.stdin.on('keypress', (ch, key) => {
312
+ if (!key) return
313
+ if (key.ctrl && key.name === 'r' && !searchMode) {
314
+ searchMode = true; searchQuery = ''
315
+ process.stdout.write('\r\x1b[K' + c.dim(' (search): '))
316
+ } else if (searchMode) {
317
+ if (key.name === 'escape') {
318
+ searchMode = false; searchQuery = ''
319
+ process.stdout.write('\r\x1b[K')
320
+ rl.setPrompt(getPrompt()); rl.prompt()
321
+ } else if (key.name === 'return') {
322
+ searchMode = false
323
+ const match = findHistoryMatch(searchQuery)
324
+ process.stdout.write('\r\x1b[K')
325
+ if (match) { rl.write(match) }
326
+ rl.setPrompt(getPrompt()); rl.prompt()
327
+ } else if (key.name === 'backspace') {
328
+ searchQuery = searchQuery.slice(0, -1)
329
+ showSearchResult()
330
+ } else if (ch && !key.ctrl && !key.meta) {
331
+ searchQuery += ch
332
+ showSearchResult()
333
+ }
334
+ }
335
+ })
336
+
337
+ function findHistoryMatch(query) {
338
+ if (!query) return null
339
+ const hist = loadHistory()
340
+ for (let i = hist.length - 1; i >= 0; i--) {
341
+ if (hist[i].toLowerCase().includes(query.toLowerCase())) return hist[i]
342
+ }
343
+ return null
344
+ }
345
+
346
+ function showSearchResult() {
347
+ const match = findHistoryMatch(searchQuery)
348
+ const display = match ? c.dim(match) : ''
349
+ process.stdout.write(`\r\x1b[K ${c.dim('(search):')} ${searchQuery}${display ? ' → ' + display : ''}`)
350
+ }
351
+ }
352
+
353
+ // --- Stats ---
354
+ function showStats() {
355
+ console.log('')
356
+ console.log(` ${c.bold('Session Stats')}`)
357
+ console.log(` Messages: ${c.cyan(String(session.requests))}`)
358
+ console.log(` Actual cost: ${c.cyan('$' + session.totalCost.toFixed(4))}`)
359
+ console.log(` Baseline: ${c.dim('$' + session.totalBaseline.toFixed(4))}`)
360
+ console.log(` Tokens: ${c.cyan(String(session.totalTokens))}`)
361
+ const pct = session.totalBaseline > 0 ? ((session.totalSaved / session.totalBaseline) * 100).toFixed(0) : 0
362
+ console.log(` Saved: ${c.green('$' + session.totalSaved.toFixed(4) + ' (' + pct + '%)')}`)
363
+ console.log('')
364
+ }
365
+
366
+ function goodbye() {
367
+ console.log('')
368
+ if (session.requests > 0)
369
+ console.log(c.dim(` Session: ${session.requests} messages · saved ${c.green('$' + session.totalSaved.toFixed(4))}`))
370
+ console.log(c.dim(' Goodbye.\n'))
371
+ process.exit(0)
372
+ }
373
+
374
+ // --- Stream response ---
375
+ async function streamResponse(pipeMode = false) {
376
+ if (!pipeMode) process.stdout.write('\n')
377
+ const spinner = createSpinner()
378
+ if (!pipeMode) spinner.start()
379
+ let fullContent = '', model = 'unknown', usage = null, firstToken = true
380
+
381
+ try {
382
+ const requestModel = modelOverride || 'openai/gpt-4o'
383
+ const res = await fetch(PROXY_URL, {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
386
+ body: JSON.stringify({ model: requestModel, messages, stream: true }),
387
+ })
388
+ if (!res.ok) {
389
+ spinner.stop()
390
+ const err = await res.text()
391
+ console.log(c.yellow(` Error ${res.status}: ${err.slice(0, 120)}\n`))
392
+ messages.pop(); return
393
+ }
394
+
395
+ const reader = res.body.getReader()
396
+ const decoder = new TextDecoder()
397
+ const renderer = pipeMode ? null : createRenderer()
398
+ let buffer = ''
399
+
400
+ while (true) {
401
+ const { done, value } = await reader.read()
402
+ if (done) break
403
+ buffer += decoder.decode(value, { stream: true })
404
+ const lines = buffer.split('\n')
405
+ buffer = lines.pop() || ''
406
+
407
+ for (const line of lines) {
408
+ if (!line.startsWith('data: ')) continue
409
+ const data = line.slice(6).trim()
410
+ if (data === '[DONE]') continue
411
+ try {
412
+ const chunk = JSON.parse(data)
413
+ if (chunk.model) model = chunk.model
414
+ if (chunk.usage) usage = chunk.usage
415
+ if (chunk._arbiter) { Object.assign(session, extractArbiterMeta(chunk._arbiter, session)) }
416
+ const delta = chunk.choices?.[0]?.delta?.content
417
+ if (delta) {
418
+ if (firstToken) { spinner.stop(); if (!pipeMode) process.stdout.write(' '); firstToken = false }
419
+ if (pipeMode) process.stdout.write(delta)
420
+ else renderer.write(delta)
421
+ fullContent += delta
422
+ }
423
+ } catch {}
424
+ }
425
+ }
426
+
427
+ if (!pipeMode && renderer) renderer.end()
428
+ if (!firstToken && !pipeMode) process.stdout.write('\n')
429
+ if (pipeMode && fullContent) process.stdout.write('\n')
430
+
431
+ messages.push({ role: 'assistant', content: fullContent })
432
+ lastAssistantContent = fullContent
433
+ session.requests++
434
+
435
+ // Cost/token tracking
436
+ const completionTokens = usage?.completion_tokens ?? Math.ceil(fullContent.length / 4)
437
+ const promptTokens = usage?.prompt_tokens ?? 0
438
+ session.totalTokens += completionTokens + promptTokens
439
+
440
+ const actualCost = usage?.cost ?? estimateCost(model, usage)
441
+ const baselineCost = usage?.baseline_cost ?? actualCost * 2.5
442
+ const saved = Math.max(0, baselineCost - actualCost)
443
+ const pct = baselineCost > 0 ? ((saved / baselineCost) * 100).toFixed(0) : 0
444
+ session.totalCost += actualCost
445
+ session.totalBaseline += baselineCost
446
+ session.totalSaved += saved
447
+
448
+ if (!pipeMode) {
449
+ const shortModel = model.split('/').pop()
450
+ const savedStr = saved < 0.0001 ? '<$0.001' : '$' + saved.toFixed(4)
451
+ const tokenStr = completionTokens ? `${completionTokens} tokens` : ''
452
+ const meta = [`${c.cyan(shortModel)}`, tokenStr, `${c.green(savedStr + ' (' + pct + '%)')}`].filter(Boolean).join(' · ')
453
+ console.log(c.dim(` ↳ ${meta}`))
454
+ console.log('')
455
+ }
456
+ } catch (e) {
457
+ spinner.stop()
458
+ console.log(c.yellow(`\n Connection error: ${e.message}\n`))
459
+ messages.pop()
460
+ }
461
+ }
462
+
463
+ function extractArbiterMeta(arbiter, session) {
464
+ // If the proxy returns _arbiter metadata, extract savings info
465
+ const out = {}
466
+ if (arbiter.cost != null) out.totalCost = session.totalCost // handled above
467
+ return out
468
+ }
469
+
470
+ function estimateCost(model, usage) {
471
+ if (!usage) return 0.001
472
+ const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0)
473
+ return tokens * 0.000003
474
+ }
package/index.js CHANGED
@@ -1,136 +1,2 @@
1
1
  #!/usr/bin/env node
2
-
3
- /**
4
- * Arbiter CLI
5
- *
6
- * Usage:
7
- * npx arbiter-cli init Set up Arbiter in current project
8
- * npx arbiter-cli status Check connection to Arbiter
9
- * npx arbiter-cli stats View your savings
10
- */
11
-
12
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs'
13
- import { resolve } from 'path'
14
-
15
- const ARBITER_URL = 'https://arbiter-proxy-long-summit-6283.fly.dev'
16
- const ENV_LINE = `OPENAI_BASE_URL=${ARBITER_URL}/v1`
17
-
18
- const args = process.argv.slice(2)
19
- const command = args[0] || 'init'
20
-
21
- const colors = {
22
- green: (s) => `\x1b[32m${s}\x1b[0m`,
23
- cyan: (s) => `\x1b[36m${s}\x1b[0m`,
24
- dim: (s) => `\x1b[2m${s}\x1b[0m`,
25
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
26
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
27
- }
28
-
29
- console.log('')
30
- console.log(colors.green(`
31
- _ ____ ____ ___ _____ _____ ____
32
- / \\ | _ \\| __ )_ _|_ _| ____| _ \\
33
- / _ \\ | |_) | _ \\| | | | | _| | |_) |
34
- / ___ \\| _ <| |_) | | | | | |___| _ <
35
- /_/ \\_\\_| \\_\\____/___|_|_| |_____|_| \\_\\
36
- `))
37
- console.log(colors.dim(' adaptive ai inference optimization'))
38
- console.log('')
39
-
40
- if (command === 'init') {
41
- init()
42
- } else if (command === 'status') {
43
- await status()
44
- } else if (command === 'stats') {
45
- await stats()
46
- } else {
47
- console.log(` Commands:`)
48
- console.log(` ${colors.cyan('init')} Set up Arbiter in current project`)
49
- console.log(` ${colors.cyan('status')} Check connection to Arbiter`)
50
- console.log(` ${colors.cyan('stats')} View your cost savings`)
51
- console.log('')
52
- }
53
-
54
- function init() {
55
- const envPath = resolve(process.cwd(), '.env')
56
- const gitignorePath = resolve(process.cwd(), '.gitignore')
57
-
58
- // Check if already configured
59
- if (existsSync(envPath)) {
60
- const content = readFileSync(envPath, 'utf-8')
61
- if (content.includes('OPENAI_BASE_URL') && content.includes('arbiter')) {
62
- console.log(colors.green(' ✓ Already configured.'))
63
- console.log(colors.dim(` ${envPath}`))
64
- console.log('')
65
- return
66
- }
67
- }
68
-
69
- // Add to .env
70
- const line = `\n# Arbiter — route all OpenAI calls through the optimizer\n${ENV_LINE}\n`
71
-
72
- if (existsSync(envPath)) {
73
- appendFileSync(envPath, line)
74
- console.log(colors.green(' ✓ Added to existing .env'))
75
- } else {
76
- writeFileSync(envPath, `# Arbiter — route all OpenAI calls through the optimizer\n${ENV_LINE}\nOPENAI_API_KEY=sk-your-openrouter-key\n`)
77
- console.log(colors.green(' ✓ Created .env'))
78
- }
79
-
80
- // Make sure .env is in .gitignore
81
- if (existsSync(gitignorePath)) {
82
- const gi = readFileSync(gitignorePath, 'utf-8')
83
- if (!gi.includes('.env')) {
84
- appendFileSync(gitignorePath, '\n.env\n')
85
- console.log(colors.green(' ✓ Added .env to .gitignore'))
86
- }
87
- }
88
-
89
- console.log('')
90
- console.log(` ${colors.bold('Done.')} All OpenAI SDK calls in this project now route through Arbiter.`)
91
- console.log('')
92
- console.log(colors.dim(' No code changes needed. The OpenAI SDK reads OPENAI_BASE_URL automatically.'))
93
- console.log(colors.dim(` Proxy: ${ARBITER_URL}/v1`))
94
- console.log(colors.dim(' Dashboard: ' + ARBITER_URL + '/dashboard'))
95
- console.log('')
96
- }
97
-
98
- async function status() {
99
- try {
100
- const res = await fetch(`${ARBITER_URL}/health`)
101
- const data = await res.json()
102
- console.log(colors.green(' ✓ Connected'))
103
- console.log(colors.dim(` Status: ${data.status}`))
104
- console.log(colors.dim(` Version: ${data.version}`))
105
- console.log(colors.dim(` Models: ${data.models}`))
106
- console.log(colors.dim(` Requests served: ${data.requests}`))
107
- } catch (e) {
108
- console.log(colors.yellow(' ✗ Cannot reach Arbiter'))
109
- console.log(colors.dim(` ${e.message}`))
110
- }
111
- console.log('')
112
- }
113
-
114
- async function stats() {
115
- try {
116
- const res = await fetch(`${ARBITER_URL}/api/analytics`)
117
- const data = await res.json()
118
- console.log(` ${colors.bold('Savings Report')}`)
119
- console.log('')
120
- console.log(` Requests routed: ${colors.cyan(data.total_requests)}`)
121
- console.log(` Actual cost: ${colors.cyan('$' + data.total_actual_cost.toFixed(4))}`)
122
- console.log(` Would have cost: ${colors.dim('$' + data.total_baseline_cost.toFixed(4))}`)
123
- console.log(` ${colors.green('Saved: $' + data.total_savings.toFixed(4) + ' (' + data.savings_pct + '%)')}`)
124
- console.log('')
125
- if (data.models_used) {
126
- console.log(` Models used:`)
127
- for (const [model, count] of Object.entries(data.models_used)) {
128
- console.log(` ${colors.dim(model.split('/').pop())}: ${count} requests`)
129
- }
130
- }
131
- } catch (e) {
132
- console.log(colors.yellow(' ✗ Cannot reach Arbiter'))
133
- console.log(colors.dim(` ${e.message}`))
134
- }
135
- console.log('')
136
- }
2
+ import('./index.mjs')
package/index.mjs ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Arbiter CLI
5
+ *
6
+ * Usage:
7
+ * npx arbiter-cli init Set up Arbiter in current project
8
+ * npx arbiter-cli status Check connection to Arbiter
9
+ * npx arbiter-cli stats View your savings
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs'
13
+ import { resolve } from 'path'
14
+
15
+ const ARBITER_URL = 'https://arbiter-proxy-long-summit-6283.fly.dev'
16
+ const LANDING_URL = 'https://arbiter-landing.fly.dev'
17
+ const ENV_LINE = `OPENAI_BASE_URL=${ARBITER_URL}/v1`
18
+
19
+ const args = process.argv.slice(2)
20
+ const command = args[0] || 'init'
21
+
22
+ const colors = {
23
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
24
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
25
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
28
+ }
29
+
30
+ console.log('')
31
+ console.log(colors.bold(' Arbiter') + colors.dim(' — AI inference optimization'))
32
+ console.log('')
33
+
34
+ if (command === 'init') {
35
+ init()
36
+ } else if (command === 'status') {
37
+ await status()
38
+ } else if (command === 'stats') {
39
+ await stats()
40
+ } else if (command === 'chat') {
41
+ await chat()
42
+ } else {
43
+ console.log(` Commands:`)
44
+ console.log(` ${colors.cyan('init')} Set up Arbiter in current project`)
45
+ console.log(` ${colors.cyan('status')} Check connection to Arbiter`)
46
+ console.log(` ${colors.cyan('stats')} View your cost savings`)
47
+ console.log(` ${colors.cyan('chat')} Start an interactive chat session`)
48
+ console.log('')
49
+ }
50
+
51
+ function init() {
52
+ const envPath = resolve(process.cwd(), '.env')
53
+ const gitignorePath = resolve(process.cwd(), '.gitignore')
54
+
55
+ // Check if already configured
56
+ if (existsSync(envPath)) {
57
+ const content = readFileSync(envPath, 'utf-8')
58
+ if (content.includes('OPENAI_BASE_URL') && content.includes('arbiter')) {
59
+ console.log(colors.green(' ✓ Already configured.'))
60
+ console.log(colors.dim(` ${envPath}`))
61
+ console.log('')
62
+ return
63
+ }
64
+ }
65
+
66
+ // Add to .env
67
+ const line = `\n# Arbiter — route all OpenAI calls through the optimizer\n${ENV_LINE}\n`
68
+
69
+ if (existsSync(envPath)) {
70
+ appendFileSync(envPath, line)
71
+ console.log(colors.green(' ✓ Added to existing .env'))
72
+ } else {
73
+ writeFileSync(envPath, `# Arbiter — route all OpenAI calls through the optimizer\n${ENV_LINE}\nOPENAI_API_KEY=sk-your-openrouter-key\n`)
74
+ console.log(colors.green(' ✓ Created .env'))
75
+ }
76
+
77
+ // Make sure .env is in .gitignore
78
+ if (existsSync(gitignorePath)) {
79
+ const gi = readFileSync(gitignorePath, 'utf-8')
80
+ if (!gi.includes('.env')) {
81
+ appendFileSync(gitignorePath, '\n.env\n')
82
+ console.log(colors.green(' ✓ Added .env to .gitignore'))
83
+ }
84
+ }
85
+
86
+ console.log('')
87
+ console.log(` ${colors.bold('Done.')} All OpenAI SDK calls in this project now route through Arbiter.`)
88
+ console.log('')
89
+ console.log(colors.dim(' No code changes needed. The OpenAI SDK reads OPENAI_BASE_URL automatically.'))
90
+ console.log(colors.dim(` Proxy: ${ARBITER_URL}/v1`))
91
+ console.log(colors.dim(' Dashboard: ' + LANDING_URL))
92
+ console.log('')
93
+ }
94
+
95
+ async function status() {
96
+ try {
97
+ const res = await fetch(`${ARBITER_URL}/health`)
98
+ const data = await res.json()
99
+ console.log(colors.green(' ✓ Connected'))
100
+ console.log(colors.dim(` Status: ${data.status}`))
101
+ console.log(colors.dim(` Version: ${data.version}`))
102
+ console.log(colors.dim(` Models: ${data.models}`))
103
+ console.log(colors.dim(` Requests served: ${data.requests}`))
104
+ } catch (e) {
105
+ console.log(colors.yellow(' ✗ Cannot reach Arbiter'))
106
+ console.log(colors.dim(` ${e.message}`))
107
+ }
108
+ console.log('')
109
+ }
110
+
111
+ async function chat() {
112
+ const { spawn } = await import('child_process')
113
+ const chatPath = new URL('./chat.mjs', import.meta.url).pathname
114
+ const child = spawn('node', [chatPath], { stdio: 'inherit' })
115
+ await new Promise((resolve) => child.on('close', resolve))
116
+ }
117
+
118
+ async function stats() {
119
+ try {
120
+ const res = await fetch(`${ARBITER_URL}/api/analytics`)
121
+ const data = await res.json()
122
+
123
+ const actualCost = data.total_actual_cost ?? data.total_cost ?? 0
124
+ const baselineCost = data.total_baseline_cost ?? data.baseline_cost ?? 0
125
+ const savings = data.total_savings ?? 0
126
+ const pct = data.savings_pct ?? 0
127
+
128
+ console.log(` ${colors.bold('Savings Report')}`)
129
+ console.log('')
130
+ console.log(` Requests routed: ${colors.cyan(data.total_requests)}`)
131
+ console.log(` Actual cost: ${colors.cyan('$' + actualCost.toFixed(4))}`)
132
+ console.log(` Would have cost: ${colors.dim('$' + baselineCost.toFixed(4))}`)
133
+ console.log(` ${colors.green('Saved: $' + savings.toFixed(4) + ' (' + pct + '%)')}`)
134
+ console.log('')
135
+ if (data.models_used) {
136
+ console.log(` Models used:`)
137
+ for (const [model, count] of Object.entries(data.models_used)) {
138
+ console.log(` ${colors.dim(model.split('/').pop())}: ${count} requests`)
139
+ }
140
+ }
141
+ } catch (e) {
142
+ console.log(colors.yellow(' ✗ Cannot reach Arbiter'))
143
+ console.log(colors.dim(` ${e.message}`))
144
+ }
145
+ console.log('')
146
+ }
package/package.json CHANGED
@@ -1,17 +1,21 @@
1
1
  {
2
2
  "name": "arbiter-cli",
3
- "version": "0.1.4",
4
- "description": "Set up Arbiter in any project with one command",
3
+ "version": "0.2.1",
4
+ "description": "AI inference optimization smart routing saves 69% on LLM costs",
5
5
  "bin": {
6
+ "arbiter": "./index.js",
6
7
  "arbiter-cli": "./index.js"
7
8
  },
8
9
  "type": "module",
9
- "files": ["index.js"],
10
- "keywords": ["llm", "proxy", "openai", "cost", "optimization", "arbiter"],
10
+ "files": ["index.js", "index.mjs", "chat.mjs"],
11
+ "keywords": ["llm", "proxy", "openai", "cost", "optimization", "arbiter", "ai", "routing", "claude", "gpt"],
11
12
  "author": "Muhammad Saqib",
12
13
  "license": "MIT",
13
14
  "repository": {
14
15
  "type": "git",
15
16
  "url": "https://github.com/saaqib-v7/Arbiter"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.0.0"
16
20
  }
17
21
  }