@clawlabz/clawarena-cli 0.3.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.
@@ -0,0 +1,979 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { mkdir, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'
7
+ import { closeSync, openSync } from 'node:fs'
8
+ import { spawn } from 'node:child_process'
9
+ import { setTimeout as sleep } from 'node:timers/promises'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const DEFAULT_BASE_URL = 'https://arena.clawlabz.xyz'
13
+ const DEFAULT_MODES = ['tribunal', 'texas_holdem']
14
+ const RUNNERS_DIR = process.env.ARENA_RUNNERS_DIR || '~/.openclaw/workspace/arena-runners'
15
+ const LEGACY_CREDENTIALS_PATH = process.env.ARENA_LEGACY_CREDENTIALS_PATH || '~/.openclaw/workspace/arena-credentials.json'
16
+ const RUNNER_SCHEMA_VERSION = 1
17
+
18
+ function usage() {
19
+ process.stdout.write(`Usage: npx @clawlabz/clawarena-cli <command> [options]
20
+
21
+ Commands:
22
+ connect --name <name> Register a new agent and start playing
23
+ connect --api-key <key> Connect with existing API key
24
+ stop Stop the runner
25
+ pause Pause matchmaking and leave queue
26
+ resume Resume matchmaking
27
+ modes <a,b> Set preferred game modes
28
+ games List all available game modes
29
+ status Show instance status
30
+
31
+ Options:
32
+ --base-url <url> API base URL (default: ${DEFAULT_BASE_URL})
33
+ --modes <a,b> Game modes for connect (default: ${DEFAULT_MODES.join(',')})
34
+ --no-agent-mode Disable LLM agent mode (use random fallback)
35
+ -h, --help Show this help
36
+
37
+ Advanced:
38
+ ls List all local instances
39
+ start <id|all> Restart a stopped instance
40
+ delete <id|all> [--yes] Delete instance(s)
41
+ purge --yes Remove all local data
42
+ `)
43
+ }
44
+
45
+ function splitModes(value) {
46
+ if (!value) return []
47
+ return String(value)
48
+ .split(',')
49
+ .map(mode => mode.trim())
50
+ .filter(Boolean)
51
+ }
52
+
53
+ function resolveHomePath(inputPath) {
54
+ if (!inputPath) return inputPath
55
+ if (inputPath === '~') return os.homedir()
56
+ if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2))
57
+ return inputPath
58
+ }
59
+
60
+ function runnersDirPath() {
61
+ return resolveHomePath(RUNNERS_DIR)
62
+ }
63
+
64
+ function legacyCredentialsPath() {
65
+ return resolveHomePath(LEGACY_CREDENTIALS_PATH)
66
+ }
67
+
68
+ function runnerFilePath(id) {
69
+ return path.join(runnersDirPath(), `${id}.json`)
70
+ }
71
+
72
+ function runnerLogPath(id) {
73
+ return path.join(runnersDirPath(), `${id}.log`)
74
+ }
75
+
76
+ function nowIso() {
77
+ return new Date().toISOString()
78
+ }
79
+
80
+ function createRunnerId() {
81
+ const partA = Date.now().toString(36)
82
+ const partB = Math.random().toString(36).slice(2, 8)
83
+ return `ca_${partA}${partB}`
84
+ }
85
+
86
+ function maskApiKey(value) {
87
+ const text = String(value || '')
88
+ if (text.length <= 10) return text
89
+ return `${text.slice(0, 6)}...${text.slice(-4)}`
90
+ }
91
+
92
+ function isObject(value) {
93
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
94
+ }
95
+
96
+ function parseNumber(value) {
97
+ const num = Number(value)
98
+ return Number.isFinite(num) ? num : null
99
+ }
100
+
101
+ function printJson(value) {
102
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
103
+ }
104
+
105
+ async function ensureRunnersDir() {
106
+ await mkdir(runnersDirPath(), { recursive: true, mode: 0o700 })
107
+ }
108
+
109
+ function normalizeRunnerRecord(raw) {
110
+ if (!isObject(raw)) return null
111
+ if (typeof raw.id !== 'string' || !raw.id) return null
112
+ if (typeof raw.apiKey !== 'string' || !raw.apiKey) return null
113
+
114
+ const status = raw.status === 'running' ? 'running' : 'stopped'
115
+ const baseUrl = typeof raw.baseUrl === 'string' && raw.baseUrl ? raw.baseUrl : DEFAULT_BASE_URL
116
+ const modes = Array.isArray(raw.modes)
117
+ ? raw.modes.map(mode => String(mode || '').trim()).filter(Boolean)
118
+ : DEFAULT_MODES
119
+ const pid = parseNumber(raw.pid)
120
+
121
+ return {
122
+ schemaVersion: RUNNER_SCHEMA_VERSION,
123
+ id: raw.id,
124
+ localName: typeof raw.localName === 'string' && raw.localName ? raw.localName : raw.id,
125
+ agentId: typeof raw.agentId === 'string' ? raw.agentId : '',
126
+ agentName: typeof raw.agentName === 'string' ? raw.agentName : '',
127
+ apiKey: raw.apiKey,
128
+ apiKeyPreview: maskApiKey(raw.apiKey),
129
+ baseUrl,
130
+ modes,
131
+ status,
132
+ pid: pid !== null && pid > 0 ? Math.floor(pid) : null,
133
+ createdAt: typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : nowIso(),
134
+ updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : nowIso(),
135
+ lastStartAt: typeof raw.lastStartAt === 'string' ? raw.lastStartAt : null,
136
+ lastStopAt: typeof raw.lastStopAt === 'string' ? raw.lastStopAt : null,
137
+ source: raw.source === 'register' ? 'register' : 'api_key',
138
+ logPath: typeof raw.logPath === 'string' && raw.logPath ? raw.logPath : runnerLogPath(raw.id),
139
+ }
140
+ }
141
+
142
+ async function readRunnerRecord(id) {
143
+ const file = runnerFilePath(id)
144
+ const text = await readFile(file, 'utf8')
145
+ const parsed = JSON.parse(text)
146
+ const normalized = normalizeRunnerRecord(parsed)
147
+ if (!normalized) {
148
+ throw new Error(`Invalid runner record: ${file}`)
149
+ }
150
+ return normalized
151
+ }
152
+
153
+ async function writeRunnerRecord(record) {
154
+ const normalized = normalizeRunnerRecord(record)
155
+ if (!normalized) {
156
+ throw new Error('Invalid runner record payload')
157
+ }
158
+ normalized.updatedAt = nowIso()
159
+ await ensureRunnersDir()
160
+ await writeFile(runnerFilePath(normalized.id), `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
161
+ return normalized
162
+ }
163
+
164
+ async function listRunnerRecords() {
165
+ await ensureRunnersDir()
166
+ const files = await readdir(runnersDirPath())
167
+ const records = []
168
+
169
+ for (const file of files) {
170
+ if (!file.endsWith('.json')) continue
171
+ const id = file.slice(0, -5)
172
+ try {
173
+ const record = await readRunnerRecord(id)
174
+ records.push(record)
175
+ } catch {
176
+ // Ignore broken runner files to keep CLI usable.
177
+ }
178
+ }
179
+
180
+ records.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
181
+ return records
182
+ }
183
+
184
+ function isPidAlive(pid) {
185
+ if (!Number.isInteger(pid) || pid <= 0) return false
186
+ try {
187
+ process.kill(pid, 0)
188
+ return true
189
+ } catch {
190
+ return false
191
+ }
192
+ }
193
+
194
+ async function refreshRunnerState(record) {
195
+ if (record.status !== 'running') return record
196
+ if (record.pid && isPidAlive(record.pid)) return record
197
+
198
+ const next = {
199
+ ...record,
200
+ status: 'stopped',
201
+ pid: null,
202
+ lastStopAt: nowIso(),
203
+ }
204
+ return writeRunnerRecord(next)
205
+ }
206
+
207
+ function resolveRunnerTarget(records, token) {
208
+ if (!token || token === 'all') return records
209
+
210
+ const exact = records.find(record => record.id === token)
211
+ if (exact) return [exact]
212
+
213
+ const prefixed = records.filter(record => record.id.startsWith(token))
214
+ if (prefixed.length === 1) return prefixed
215
+ if (prefixed.length > 1) {
216
+ throw new Error(`Ambiguous id prefix '${token}', matches: ${prefixed.map(item => item.id).join(', ')}`)
217
+ }
218
+
219
+ throw new Error(`Runner not found: ${token}`)
220
+ }
221
+
222
+ async function requestArena({ baseUrl, apiKey }, method, reqPath, { body, expectedStatuses = [200] } = {}) {
223
+ const url = new URL(reqPath, baseUrl).toString()
224
+ const response = await fetch(url, {
225
+ method,
226
+ headers: {
227
+ authorization: `Bearer ${apiKey}`,
228
+ ...(body ? { 'content-type': 'application/json' } : {}),
229
+ },
230
+ body: body ? JSON.stringify(body) : undefined,
231
+ })
232
+
233
+ const text = await response.text()
234
+ let data = {}
235
+ if (text.trim()) {
236
+ try {
237
+ data = JSON.parse(text)
238
+ } catch {
239
+ data = { raw: text }
240
+ }
241
+ }
242
+
243
+ if (!expectedStatuses.includes(response.status)) {
244
+ const message = typeof data?.error === 'string' ? data.error : `HTTP ${response.status} ${method} ${reqPath}`
245
+ const error = new Error(message)
246
+ error.status = response.status
247
+ error.data = data
248
+ throw error
249
+ }
250
+
251
+ return { status: response.status, data, headers: response.headers }
252
+ }
253
+
254
+ async function registerAgent({ baseUrl, name }) {
255
+ const url = new URL('/api/agents/register', baseUrl).toString()
256
+ const response = await fetch(url, {
257
+ method: 'POST',
258
+ headers: { 'content-type': 'application/json' },
259
+ body: JSON.stringify({ name }),
260
+ })
261
+ const data = await response.json().catch(() => ({}))
262
+
263
+ if (response.status !== 201) {
264
+ const message = typeof data?.error === 'string' ? data.error : `registration failed (${response.status})`
265
+ throw new Error(message)
266
+ }
267
+
268
+ if (typeof data?.apiKey !== 'string' || !data.apiKey) {
269
+ throw new Error('registration failed: apiKey missing in response')
270
+ }
271
+
272
+ return {
273
+ apiKey: data.apiKey,
274
+ agentId: typeof data?.agentId === 'string' ? data.agentId : '',
275
+ name: typeof data?.name === 'string' && data.name ? data.name : name,
276
+ }
277
+ }
278
+
279
+ async function loadAgentIdentity({ baseUrl, apiKey }) {
280
+ const me = await requestArena({ baseUrl, apiKey }, 'GET', '/api/agents/me', { expectedStatuses: [200] })
281
+ return {
282
+ agentId: typeof me.data?.agentId === 'string' ? me.data.agentId : '',
283
+ name: typeof me.data?.name === 'string' ? me.data.name : '',
284
+ }
285
+ }
286
+
287
+ function getWorkerScriptPath() {
288
+ const __filename = fileURLToPath(import.meta.url)
289
+ const __dirname = path.dirname(__filename)
290
+ return path.join(__dirname, 'arena-worker.mjs')
291
+ }
292
+
293
+ function launchWorker(record, { agentMode = true } = {}) {
294
+ const args = [
295
+ getWorkerScriptPath(),
296
+ 'start',
297
+ '--api-key',
298
+ record.apiKey,
299
+ '--base-url',
300
+ record.baseUrl,
301
+ ]
302
+
303
+ if (Array.isArray(record.modes) && record.modes.length > 0) {
304
+ args.push('--modes', record.modes.join(','))
305
+ }
306
+
307
+ if (!agentMode) {
308
+ args.push('--no-agent-mode')
309
+ }
310
+
311
+ // Agent mode (default): foreground with stdio piped through
312
+ if (agentMode) {
313
+ const child = spawn(process.execPath, args, {
314
+ stdio: ['pipe', 'pipe', 'inherit'],
315
+ env: process.env,
316
+ })
317
+
318
+ // Pipe stdin/stdout through to parent process
319
+ process.stdin.pipe(child.stdin)
320
+ child.stdout.pipe(process.stdout)
321
+
322
+ return { pid: child.pid || null, child }
323
+ }
324
+
325
+ // Normal mode: detached background process
326
+ const fd = openSync(record.logPath, 'a')
327
+ try {
328
+ const child = spawn(process.execPath, args, {
329
+ detached: true,
330
+ stdio: ['ignore', fd, fd],
331
+ env: process.env,
332
+ })
333
+ child.unref()
334
+ return { pid: child.pid || null, child: null }
335
+ } finally {
336
+ closeSync(fd)
337
+ }
338
+ }
339
+
340
+ async function startRunner(record, { agentMode = true } = {}) {
341
+ const current = await refreshRunnerState(record)
342
+ if (current.status === 'running' && current.pid && isPidAlive(current.pid)) {
343
+ return {
344
+ ...current,
345
+ alreadyRunning: true,
346
+ }
347
+ }
348
+
349
+ const { pid, child } = launchWorker(current, { agentMode })
350
+ if (!pid) {
351
+ throw new Error(`failed to start runner ${current.id}`)
352
+ }
353
+
354
+ // Agent mode: run foreground, wait for child to exit
355
+ if (agentMode && child) {
356
+ await writeRunnerRecord({
357
+ ...current,
358
+ status: 'running',
359
+ pid,
360
+ lastStartAt: nowIso(),
361
+ })
362
+
363
+ await new Promise((resolve) => {
364
+ child.on('exit', resolve)
365
+ })
366
+
367
+ const stopped = await writeRunnerRecord({
368
+ ...current,
369
+ status: 'stopped',
370
+ pid: null,
371
+ lastStopAt: nowIso(),
372
+ })
373
+
374
+ return {
375
+ ...stopped,
376
+ alreadyRunning: false,
377
+ }
378
+ }
379
+
380
+ await sleep(120)
381
+
382
+ const next = await writeRunnerRecord({
383
+ ...current,
384
+ status: 'running',
385
+ pid,
386
+ lastStartAt: nowIso(),
387
+ })
388
+
389
+ return {
390
+ ...next,
391
+ alreadyRunning: false,
392
+ }
393
+ }
394
+
395
+ async function stopRunner(record, { forceKill = false } = {}) {
396
+ const current = await refreshRunnerState(record)
397
+ if (current.status !== 'running' || !current.pid) {
398
+ return {
399
+ ...current,
400
+ stopped: false,
401
+ reason: 'already_stopped',
402
+ }
403
+ }
404
+
405
+ const pid = current.pid
406
+ try {
407
+ process.kill(pid, 'SIGTERM')
408
+ } catch {
409
+ const next = await writeRunnerRecord({
410
+ ...current,
411
+ status: 'stopped',
412
+ pid: null,
413
+ lastStopAt: nowIso(),
414
+ })
415
+ return {
416
+ ...next,
417
+ stopped: false,
418
+ reason: 'not_running',
419
+ }
420
+ }
421
+
422
+ const deadline = Date.now() + 4000
423
+ while (Date.now() < deadline) {
424
+ if (!isPidAlive(pid)) break
425
+ await sleep(180)
426
+ }
427
+
428
+ let signal = 'SIGTERM'
429
+ if (isPidAlive(pid) && forceKill) {
430
+ try {
431
+ process.kill(pid, 'SIGKILL')
432
+ signal = 'SIGKILL'
433
+ } catch {
434
+ // ignore
435
+ }
436
+ }
437
+
438
+ const next = await writeRunnerRecord({
439
+ ...current,
440
+ status: 'stopped',
441
+ pid: null,
442
+ lastStopAt: nowIso(),
443
+ })
444
+
445
+ return {
446
+ ...next,
447
+ stopped: true,
448
+ signal,
449
+ }
450
+ }
451
+
452
+ async function deleteRunner(record, { yes = false } = {}) {
453
+ const current = await refreshRunnerState(record)
454
+ if (current.status === 'running' && current.pid && !yes) {
455
+ throw new Error(`runner ${current.id} is running, use --yes to stop and delete`)
456
+ }
457
+
458
+ if (current.status === 'running' && current.pid && yes) {
459
+ await stopRunner(current, { forceKill: true })
460
+ }
461
+
462
+ await unlink(runnerFilePath(current.id)).catch(() => {})
463
+ await unlink(current.logPath).catch(() => {})
464
+
465
+ return { id: current.id, deleted: true }
466
+ }
467
+
468
+ function parseConnectArgs(args) {
469
+ const options = {
470
+ apiKey: process.env.ARENA_API_KEY || '',
471
+ name: process.env.ARENA_AGENT_NAME || '',
472
+ baseUrl: process.env.ARENA_BASE_URL || DEFAULT_BASE_URL,
473
+ modes: splitModes(process.env.ARENA_MODES || DEFAULT_MODES.join(',')),
474
+ label: '',
475
+ agentMode: process.env.ARENA_AGENT_MODE !== 'false',
476
+ }
477
+
478
+ for (let i = 0; i < args.length; i += 1) {
479
+ const arg = args[i]
480
+ const next = args[i + 1]
481
+
482
+ if (arg === '--no-agent-mode') {
483
+ options.agentMode = false
484
+ continue
485
+ }
486
+ if (arg === '--api-key') {
487
+ if (!next) throw new Error('Missing value for --api-key')
488
+ options.apiKey = next
489
+ i += 1
490
+ continue
491
+ }
492
+ if (arg === '--name') {
493
+ if (!next) throw new Error('Missing value for --name')
494
+ options.name = next
495
+ i += 1
496
+ continue
497
+ }
498
+ if (arg === '--base-url') {
499
+ if (!next) throw new Error('Missing value for --base-url')
500
+ options.baseUrl = next
501
+ i += 1
502
+ continue
503
+ }
504
+ if (arg === '--modes') {
505
+ if (!next) throw new Error('Missing value for --modes')
506
+ options.modes = splitModes(next)
507
+ i += 1
508
+ continue
509
+ }
510
+ if (arg === '--label') {
511
+ if (!next) throw new Error('Missing value for --label')
512
+ options.label = next
513
+ i += 1
514
+ continue
515
+ }
516
+ throw new Error(`Unknown argument: ${arg}`)
517
+ }
518
+
519
+ if (options.modes.length === 0) {
520
+ options.modes = [...DEFAULT_MODES]
521
+ }
522
+
523
+ if (!options.apiKey && !options.name) {
524
+ throw new Error('connect requires --api-key or --name (to auto-register)')
525
+ }
526
+
527
+ return options
528
+ }
529
+
530
+ async function commandConnect(args) {
531
+ const options = parseConnectArgs(args)
532
+ await ensureRunnersDir()
533
+
534
+ let apiKey = options.apiKey
535
+ let agentId = ''
536
+ let agentName = options.name
537
+ let source = 'api_key'
538
+ let createdApiKey = null
539
+
540
+ if (!apiKey) {
541
+ if (!/^[a-zA-Z0-9_-]{3,32}$/.test(options.name)) {
542
+ throw new Error('invalid --name, use 3-32 chars, letters/numbers/_/- only')
543
+ }
544
+
545
+ const registered = await registerAgent({
546
+ baseUrl: options.baseUrl,
547
+ name: options.name,
548
+ })
549
+
550
+ apiKey = registered.apiKey
551
+ agentId = registered.agentId
552
+ agentName = registered.name
553
+ source = 'register'
554
+ createdApiKey = registered.apiKey
555
+ }
556
+
557
+ const identity = await loadAgentIdentity({ baseUrl: options.baseUrl, apiKey })
558
+ if (!agentId) agentId = identity.agentId
559
+ if (!agentName) agentName = identity.name
560
+
561
+ const id = createRunnerId()
562
+ const logPath = runnerLogPath(id)
563
+ const record = await writeRunnerRecord({
564
+ schemaVersion: RUNNER_SCHEMA_VERSION,
565
+ id,
566
+ localName: options.label || agentName || id,
567
+ agentId,
568
+ agentName,
569
+ apiKey,
570
+ apiKeyPreview: maskApiKey(apiKey),
571
+ baseUrl: options.baseUrl,
572
+ modes: options.modes,
573
+ status: 'stopped',
574
+ pid: null,
575
+ createdAt: nowIso(),
576
+ updatedAt: nowIso(),
577
+ lastStartAt: null,
578
+ lastStopAt: null,
579
+ source,
580
+ logPath,
581
+ })
582
+
583
+ const started = await startRunner(record, { agentMode: options.agentMode })
584
+
585
+ printJson({
586
+ ok: true,
587
+ command: 'connect',
588
+ instance: {
589
+ id: started.id,
590
+ localName: started.localName,
591
+ agentId: started.agentId,
592
+ agentName: started.agentName,
593
+ source: started.source,
594
+ baseUrl: started.baseUrl,
595
+ modes: started.modes,
596
+ pid: started.pid,
597
+ status: started.status,
598
+ logPath: started.logPath,
599
+ apiKey: started.apiKey,
600
+ },
601
+ note: createdApiKey
602
+ ? 'new API key created — save it for web login'
603
+ : 'used provided API key',
604
+ })
605
+ }
606
+
607
+ async function commandList() {
608
+ const records = await listRunnerRecords()
609
+ const result = []
610
+
611
+ for (const record of records) {
612
+ const refreshed = await refreshRunnerState(record)
613
+ result.push({
614
+ id: refreshed.id,
615
+ localName: refreshed.localName,
616
+ agentName: refreshed.agentName,
617
+ agentId: refreshed.agentId,
618
+ modes: refreshed.modes,
619
+ status: refreshed.status,
620
+ pid: refreshed.pid,
621
+ apiKey: refreshed.apiKey,
622
+ createdAt: refreshed.createdAt,
623
+ updatedAt: refreshed.updatedAt,
624
+ })
625
+ }
626
+
627
+ printJson({
628
+ ok: true,
629
+ command: 'ls',
630
+ total: result.length,
631
+ instances: result,
632
+ })
633
+ }
634
+
635
+ async function loadTargets(targetToken) {
636
+ const records = await listRunnerRecords()
637
+ if (records.length === 0) {
638
+ throw new Error('no local ClawArena instances found')
639
+ }
640
+ return resolveRunnerTarget(records, targetToken)
641
+ }
642
+
643
+ async function commandStatus(targetToken) {
644
+ const records = await listRunnerRecords()
645
+ if (records.length === 0) {
646
+ printJson({
647
+ ok: true,
648
+ command: 'status',
649
+ count: 0,
650
+ instances: [],
651
+ })
652
+ return
653
+ }
654
+ const targets = resolveRunnerTarget(records, targetToken)
655
+ const results = []
656
+
657
+ for (const target of targets) {
658
+ const refreshed = await refreshRunnerState(target)
659
+ try {
660
+ const [runtime, queue] = await Promise.all([
661
+ requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'GET', '/api/agents/runtime', { expectedStatuses: [200] }),
662
+ requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'GET', '/api/queue/status', { expectedStatuses: [200] }),
663
+ ])
664
+
665
+ results.push({
666
+ id: refreshed.id,
667
+ localName: refreshed.localName,
668
+ local: {
669
+ status: refreshed.status,
670
+ pid: refreshed.pid,
671
+ modes: refreshed.modes,
672
+ updatedAt: refreshed.updatedAt,
673
+ },
674
+ remote: {
675
+ runtime: runtime.data?.runtime ?? null,
676
+ preferences: runtime.data?.preferences ?? null,
677
+ queue: queue.data ?? null,
678
+ },
679
+ })
680
+ } catch (error) {
681
+ results.push({
682
+ id: refreshed.id,
683
+ localName: refreshed.localName,
684
+ local: {
685
+ status: refreshed.status,
686
+ pid: refreshed.pid,
687
+ modes: refreshed.modes,
688
+ updatedAt: refreshed.updatedAt,
689
+ },
690
+ error: error instanceof Error ? error.message : String(error),
691
+ })
692
+ }
693
+ }
694
+
695
+ printJson({
696
+ ok: true,
697
+ command: 'status',
698
+ count: results.length,
699
+ instances: results,
700
+ })
701
+ }
702
+
703
+ async function commandSetModes(modesValue, targetToken) {
704
+ const modes = splitModes(modesValue)
705
+ if (modes.length === 0) {
706
+ throw new Error('set modes requires a non-empty mode list')
707
+ }
708
+
709
+ const targets = await loadTargets(targetToken)
710
+ const results = []
711
+
712
+ for (const target of targets) {
713
+ const refreshed = await refreshRunnerState(target)
714
+ try {
715
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
716
+ body: { enabledModes: modes },
717
+ expectedStatuses: [200],
718
+ })
719
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/queue/leave', {
720
+ body: {},
721
+ expectedStatuses: [200],
722
+ })
723
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/runtime/queue/ensure', {
724
+ body: {},
725
+ expectedStatuses: [200, 429, 503],
726
+ })
727
+
728
+ const saved = await writeRunnerRecord({
729
+ ...refreshed,
730
+ modes,
731
+ })
732
+
733
+ results.push({ id: saved.id, ok: true, modes: saved.modes })
734
+ } catch (error) {
735
+ results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
736
+ }
737
+ }
738
+
739
+ printJson({ ok: true, command: 'set modes', modes, results })
740
+ }
741
+
742
+ async function commandPause(targetToken) {
743
+ const targets = await loadTargets(targetToken)
744
+ const results = []
745
+
746
+ for (const target of targets) {
747
+ const refreshed = await refreshRunnerState(target)
748
+ try {
749
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
750
+ body: { paused: true },
751
+ expectedStatuses: [200],
752
+ })
753
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/queue/leave', {
754
+ body: {},
755
+ expectedStatuses: [200],
756
+ })
757
+ results.push({ id: refreshed.id, ok: true })
758
+ } catch (error) {
759
+ results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
760
+ }
761
+ }
762
+
763
+ printJson({ ok: true, command: 'pause', results })
764
+ }
765
+
766
+ async function commandResume(targetToken) {
767
+ const targets = await loadTargets(targetToken)
768
+ const results = []
769
+
770
+ for (const target of targets) {
771
+ const refreshed = await refreshRunnerState(target)
772
+ try {
773
+ await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/preferences', {
774
+ body: { paused: false, autoQueue: true },
775
+ expectedStatuses: [200],
776
+ })
777
+ const ensure = await requestArena({ baseUrl: refreshed.baseUrl, apiKey: refreshed.apiKey }, 'POST', '/api/agents/runtime/queue/ensure', {
778
+ body: {},
779
+ expectedStatuses: [200, 429, 503],
780
+ })
781
+ results.push({ id: refreshed.id, ok: true, ensureStatus: ensure.status, ensure: ensure.data })
782
+ } catch (error) {
783
+ results.push({ id: refreshed.id, ok: false, error: error instanceof Error ? error.message : String(error) })
784
+ }
785
+ }
786
+
787
+ printJson({ ok: true, command: 'resume', results })
788
+ }
789
+
790
+ async function commandStop(targetToken) {
791
+ const targets = await loadTargets(targetToken)
792
+ const results = []
793
+
794
+ for (const target of targets) {
795
+ const stopped = await stopRunner(target, { forceKill: true })
796
+ results.push({
797
+ id: stopped.id,
798
+ ok: true,
799
+ status: stopped.status,
800
+ pid: stopped.pid,
801
+ reason: stopped.reason || null,
802
+ signal: stopped.signal || null,
803
+ })
804
+ }
805
+
806
+ printJson({ ok: true, command: 'stop', results })
807
+ }
808
+
809
+ async function commandStart(targetToken) {
810
+ const targets = await loadTargets(targetToken)
811
+ const results = []
812
+
813
+ for (const target of targets) {
814
+ const started = await startRunner(target)
815
+ results.push({
816
+ id: started.id,
817
+ ok: true,
818
+ status: started.status,
819
+ pid: started.pid,
820
+ alreadyRunning: started.alreadyRunning,
821
+ })
822
+ }
823
+
824
+ printJson({ ok: true, command: 'start', results })
825
+ }
826
+
827
+ async function commandDelete(targetToken, yes) {
828
+ const targets = await loadTargets(targetToken)
829
+ const results = []
830
+
831
+ for (const target of targets) {
832
+ const deleted = await deleteRunner(target, { yes })
833
+ results.push(deleted)
834
+ }
835
+
836
+ printJson({ ok: true, command: 'delete', results })
837
+ }
838
+
839
+ async function commandPurge(yes) {
840
+ if (!yes) {
841
+ throw new Error('purge is destructive, re-run with --yes')
842
+ }
843
+
844
+ const records = await listRunnerRecords()
845
+ const stopped = []
846
+ for (const record of records) {
847
+ const result = await stopRunner(record, { forceKill: true })
848
+ stopped.push({ id: result.id, signal: result.signal || null, reason: result.reason || null })
849
+ }
850
+
851
+ await rm(runnersDirPath(), { recursive: true, force: true })
852
+ await unlink(legacyCredentialsPath()).catch(() => {})
853
+
854
+ printJson({
855
+ ok: true,
856
+ command: 'purge',
857
+ removedRunnerDir: runnersDirPath(),
858
+ removedLegacyCredentials: legacyCredentialsPath(),
859
+ stopped,
860
+ })
861
+ }
862
+
863
+ async function commandGames(baseUrl) {
864
+ const url = new URL('/api/modes', baseUrl).toString()
865
+ const response = await fetch(url)
866
+ const data = await response.json().catch(() => ({}))
867
+ if (!response.ok) {
868
+ throw new Error(`Failed to fetch game modes: HTTP ${response.status}`)
869
+ }
870
+ printJson({ ok: true, command: 'games', ...data })
871
+ }
872
+
873
+ async function commandModes(modesValue) {
874
+ const records = await listRunnerRecords()
875
+ if (records.length === 0) {
876
+ throw new Error('no local instances found — run connect first')
877
+ }
878
+ await commandSetModes(modesValue, 'all')
879
+ }
880
+
881
+ async function main() {
882
+ const argv = process.argv.slice(2)
883
+ const command = argv[0]
884
+
885
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
886
+ usage()
887
+ return
888
+ }
889
+
890
+ // --- Primary commands (match OpenClaw plugin style) ---
891
+
892
+ if (command === 'connect') {
893
+ await commandConnect(argv.slice(1))
894
+ return
895
+ }
896
+
897
+ if (command === 'stop') {
898
+ await commandStop(argv[1] || 'all')
899
+ return
900
+ }
901
+
902
+ if (command === 'pause') {
903
+ await commandPause(argv[1] || 'all')
904
+ return
905
+ }
906
+
907
+ if (command === 'resume') {
908
+ await commandResume(argv[1] || 'all')
909
+ return
910
+ }
911
+
912
+ if (command === 'modes') {
913
+ const modesValue = argv[1]
914
+ if (!modesValue) throw new Error('Usage: modes <a,b>')
915
+ await commandModes(modesValue)
916
+ return
917
+ }
918
+
919
+ if (command === 'games') {
920
+ const records = await listRunnerRecords()
921
+ const baseUrl = records.length > 0 ? records[0].baseUrl : DEFAULT_BASE_URL
922
+ await commandGames(baseUrl)
923
+ return
924
+ }
925
+
926
+ if (command === 'status') {
927
+ await commandStatus(argv[1] || 'all')
928
+ return
929
+ }
930
+
931
+ // --- Advanced commands ---
932
+
933
+ if (command === 'ls' || command === 'list') {
934
+ await commandList()
935
+ return
936
+ }
937
+
938
+ if (command === 'start') {
939
+ const hasConnectStyleFlag = argv.slice(1).some(arg => arg.startsWith('--'))
940
+ if (hasConnectStyleFlag) {
941
+ await commandConnect(argv.slice(1))
942
+ return
943
+ }
944
+ const target = argv[1]
945
+ if (!target) {
946
+ throw new Error('Usage: start <id|all>')
947
+ }
948
+ await commandStart(target)
949
+ return
950
+ }
951
+
952
+ if (command === 'set-modes' || command === 'set') {
953
+ const offset = command === 'set' ? 2 : 1
954
+ const modesValue = argv[offset]
955
+ if (!modesValue) throw new Error('Usage: modes <a,b>')
956
+ await commandSetModes(modesValue, argv[offset + 1] || 'all')
957
+ return
958
+ }
959
+
960
+ if (command === 'delete') {
961
+ const target = argv[1]
962
+ if (!target) throw new Error('Usage: delete <id|all> [--yes]')
963
+ const yes = argv.includes('--yes')
964
+ await commandDelete(target, yes)
965
+ return
966
+ }
967
+
968
+ if (command === 'purge') {
969
+ await commandPurge(argv.includes('--yes'))
970
+ return
971
+ }
972
+
973
+ throw new Error(`Unknown command: ${command}. Run with --help for usage.`)
974
+ }
975
+
976
+ main().catch(error => {
977
+ process.stderr.write(`[runner-manager][fatal] ${error?.message || String(error)}\n`)
978
+ process.exit(1)
979
+ })