codex-to-poke 0.1.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/src/index.js ADDED
@@ -0,0 +1,825 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { createInterface } from 'node:readline';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = ['help', '--help', '-h'].includes(args[0] ?? '')
13
+ ? 'help'
14
+ : args[0]?.startsWith('-')
15
+ ? 'local'
16
+ : (args[0] ?? 'local');
17
+ const invocationCwd = process.env.INIT_CWD ?? process.cwd();
18
+ const managedChildren = new Map();
19
+ const serviceLogs = new Map();
20
+ const statuses = { relay: 'idle', agent: 'idle', poke: 'idle' };
21
+ const bannerText = [
22
+ '▄▄▄▄ ▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄ ▄▄ ',
23
+ '██▄█▀ ██▀██ ██▄█▀ ██▄▄ ██▀██ ██▄▄ ▀█▄█▀ ',
24
+ '██ ▀███▀ ██ ██ ██▄▄▄ ████▀ ██▄▄▄ ██ ██ ',
25
+ ].join('\n');
26
+ let configPath = '';
27
+ let config = {};
28
+ let readline = null;
29
+ let stopping = false;
30
+ let restarting = false;
31
+
32
+ if (command === 'help') help();
33
+ else if (command === 'local') await local();
34
+ else die(`unknown command: ${command}`);
35
+
36
+ async function local() {
37
+ configPath = defaultConfigPath();
38
+ config = createConfig(loadSavedConfig());
39
+ saveConfig();
40
+
41
+ printBanner();
42
+
43
+ registerSignals();
44
+ try {
45
+ await startStack();
46
+ } catch (error) {
47
+ console.error(formatError(error));
48
+ await stopStack(false);
49
+ process.exit(1);
50
+ }
51
+ startConsole();
52
+ }
53
+
54
+ function createConfig(saved) {
55
+ const first = firstWorkspace(saved);
56
+ const readOnly = has('--read-only');
57
+ const writeEnabled = readOnly
58
+ ? false
59
+ : has('--write') || has('--full-access')
60
+ ? true
61
+ : (saved.writeTasksEnabled ?? first.allowWrite ?? false);
62
+ const fullAccess = readOnly
63
+ ? false
64
+ : has('--full-access')
65
+ ? true
66
+ : has('--write')
67
+ ? false
68
+ : (saved.fullAccessEnabled ?? first.allowFullAccess ?? false);
69
+ const alias = value('--alias') ?? first.alias ?? 'main';
70
+ const root = value('--workspace')
71
+ ? resolveUserPath(value('--workspace'))
72
+ : (first.root ?? resolveUserPath('.'));
73
+ const workspaces = normalizeWorkspaces(saved.workspaces, {
74
+ alias,
75
+ root,
76
+ writeEnabled,
77
+ fullAccess,
78
+ });
79
+
80
+ upsertWorkspace(workspaces, {
81
+ alias,
82
+ root,
83
+ description: first.description ?? `${alias} workspace`,
84
+ allowWrite: writeEnabled,
85
+ allowFullAccess: fullAccess,
86
+ defaultSandbox: sandboxFor(writeEnabled, fullAccess),
87
+ });
88
+
89
+ return {
90
+ port: String(value('--port') ?? saved.port ?? '3000'),
91
+ userId: value('--user-id') ?? saved.userId ?? 'local',
92
+ relayUrl: `ws://127.0.0.1:${value('--port') ?? saved.port ?? '3000'}/agent`,
93
+ relayToken: value('--token') ?? saved.relayToken ?? randomHex(),
94
+ appServerCommand: value('--codex') ?? saved.appServerCommand ?? 'codex',
95
+ appServerArgs: Array.isArray(saved.appServerArgs)
96
+ ? saved.appServerArgs
97
+ : ['app-server', '--listen', 'stdio://'],
98
+ defaultModel: value('--model') ?? saved.defaultModel ?? 'gpt-5.5',
99
+ defaultReasoning: value('--reasoning') ?? saved.defaultReasoning ?? 'medium',
100
+ defaultVerbosity: value('--verbosity') ?? saved.defaultVerbosity ?? 'medium',
101
+ defaultApprovalPolicy: value('--approval') ?? saved.defaultApprovalPolicy ?? 'on-request',
102
+ writeTasksEnabled: writeEnabled,
103
+ fullAccessEnabled: fullAccess,
104
+ workspaces,
105
+ };
106
+ }
107
+
108
+ function normalizeWorkspaces(workspaces, defaults) {
109
+ const items = Array.isArray(workspaces) ? workspaces : [];
110
+ const normalized = items
111
+ .filter(
112
+ (workspace) => workspace && typeof workspace === 'object' && workspace.alias && workspace.root
113
+ )
114
+ .map((workspace) => ({
115
+ alias: String(workspace.alias),
116
+ root: String(workspace.root),
117
+ description: workspace.description
118
+ ? String(workspace.description)
119
+ : `${workspace.alias} workspace`,
120
+ allowWrite: Boolean(workspace.allowWrite),
121
+ allowFullAccess: Boolean(workspace.allowFullAccess),
122
+ defaultSandbox:
123
+ workspace.defaultSandbox ?? sandboxFor(workspace.allowWrite, workspace.allowFullAccess),
124
+ }));
125
+
126
+ if (normalized.length) return normalized;
127
+ return [
128
+ {
129
+ alias: defaults.alias,
130
+ root: defaults.root,
131
+ description: `${defaults.alias} workspace`,
132
+ allowWrite: defaults.writeEnabled,
133
+ allowFullAccess: defaults.fullAccess,
134
+ defaultSandbox: sandboxFor(defaults.writeEnabled, defaults.fullAccess),
135
+ },
136
+ ];
137
+ }
138
+
139
+ async function startStack() {
140
+ await stopStack(false);
141
+
142
+ statuses.relay = 'starting';
143
+ spawnManaged('relay', 'pokedex-relay', ['--config', configPath]);
144
+ await waitForRelay();
145
+ statuses.relay = 'ok';
146
+
147
+ statuses.agent = 'starting';
148
+ spawnManaged('agent', 'pokedex-agent', ['--config', configPath]);
149
+ await waitForAgent();
150
+ statuses.agent = 'ok';
151
+
152
+ statuses.poke = 'starting';
153
+ spawnManaged('poke', npxBin(), ['poke@latest', 'tunnel', mcpHttpUrlWithToken(), '-n', 'pokedex']);
154
+ await waitForPoke();
155
+ statuses.poke = 'ok';
156
+ console.log("✅ Everything's fine. pokedex is ready.");
157
+ console.log('Type "help" for commands. Keep this terminal open while you use Poke.\n');
158
+ }
159
+
160
+ async function waitForRelay() {
161
+ await waitFor(
162
+ async () => {
163
+ const health = await fetchJson(`http://127.0.0.1:${config.port}/health`);
164
+ return health?.ok === true;
165
+ },
166
+ 10_000,
167
+ 'relay did not become healthy',
168
+ 'relay'
169
+ );
170
+ }
171
+
172
+ async function waitForAgent() {
173
+ await waitFor(
174
+ async () => {
175
+ const health = await fetchJson(`http://127.0.0.1:${config.port}/health`);
176
+ return Number(health?.agents ?? 0) > 0;
177
+ },
178
+ 15_000,
179
+ 'agent did not connect to relay',
180
+ 'agent'
181
+ );
182
+ }
183
+
184
+ async function waitForPoke() {
185
+ await sleep(1400);
186
+ const child = managedChildren.get('poke')?.child;
187
+ if (!child || child.exitCode !== null || child.killed)
188
+ throw serviceFailure('poke', 'Poke stopped before the tunnel was ready.');
189
+ }
190
+
191
+ async function waitFor(check, timeoutMs, message, serviceName) {
192
+ const deadline = Date.now() + timeoutMs;
193
+ while (Date.now() < deadline) {
194
+ if (serviceName) failIfServiceExited(serviceName, message);
195
+ if (await check()) return;
196
+ await sleep(250);
197
+ }
198
+ throw serviceFailure(serviceName, message);
199
+ }
200
+
201
+ function startConsole() {
202
+ readline = createInterface({
203
+ input: process.stdin,
204
+ output: process.stdout,
205
+ prompt: 'pokedex> ',
206
+ });
207
+ readline.on('line', (line) => void runConsoleCommand(line));
208
+ readline.on('close', () => void stopManaged(0));
209
+ readline.prompt();
210
+ }
211
+
212
+ async function runConsoleCommand(line) {
213
+ readline?.pause();
214
+ try {
215
+ await handleCommand(splitCommand(line.trim()));
216
+ } catch (error) {
217
+ console.error(formatError(error));
218
+ } finally {
219
+ if (!stopping) {
220
+ readline?.resume();
221
+ readline?.prompt();
222
+ }
223
+ }
224
+ }
225
+
226
+ async function handleCommand(parts) {
227
+ const [name, subcommand, ...rest] = parts;
228
+ if (!name) return printStatus();
229
+ if (['q', 'quit', 'exit'].includes(name)) return await stopManaged(0);
230
+ if (name === 'help') return printInteractiveHelp();
231
+ if (name === 'status') return printStatus();
232
+ if (name === 'config') return printConfig();
233
+ if (name === 'output') return printServiceOutput(subcommand);
234
+ if (name === 'restart') return await saveAndRestart('restarting stack');
235
+ if (name === 'write') return await setWrite(subcommand);
236
+ if (name === 'full-access') return await setFullAccess(subcommand);
237
+ if (name === 'workspace') return await handleWorkspaceCommand(subcommand, rest);
238
+ if (name === 'port') return await setPort(subcommand);
239
+ if (name === 'token' && subcommand === 'rotate') return await rotateToken();
240
+ if (name === 'user-id') return await setScalar('userId', subcommand, 'user id');
241
+ if (name === 'model') return await setScalar('defaultModel', subcommand, 'model');
242
+ if (name === 'reasoning')
243
+ return await setEnum('defaultReasoning', subcommand, [
244
+ 'minimal',
245
+ 'low',
246
+ 'medium',
247
+ 'high',
248
+ 'xhigh',
249
+ ]);
250
+ if (name === 'verbosity')
251
+ return await setEnum('defaultVerbosity', subcommand, ['low', 'medium', 'high']);
252
+ if (name === 'approval')
253
+ return await setEnum('defaultApprovalPolicy', subcommand, ['untrusted', 'on-request', 'never']);
254
+ if (name === 'codex') return await setCodex([subcommand, ...rest].filter(Boolean));
255
+ throw new Error(`Unknown command: ${name}. Type "help" for commands.`);
256
+ }
257
+
258
+ async function setWrite(raw) {
259
+ const enabled = parseOnOff(raw, !config.writeTasksEnabled);
260
+ config.writeTasksEnabled = enabled;
261
+ activeWorkspace().allowWrite = enabled;
262
+ if (!enabled) {
263
+ config.fullAccessEnabled = false;
264
+ activeWorkspace().allowFullAccess = false;
265
+ }
266
+ syncWorkspaceSandbox(activeWorkspace());
267
+ await saveAndRestart(`write ${enabled ? 'on' : 'off'}`);
268
+ }
269
+
270
+ async function setFullAccess(raw) {
271
+ const enabled = parseOnOff(raw, !config.fullAccessEnabled);
272
+ config.fullAccessEnabled = enabled;
273
+ config.writeTasksEnabled = enabled || config.writeTasksEnabled;
274
+ activeWorkspace().allowFullAccess = enabled;
275
+ activeWorkspace().allowWrite = enabled || activeWorkspace().allowWrite;
276
+ syncWorkspaceSandbox(activeWorkspace());
277
+ await saveAndRestart(`full-access ${enabled ? 'on' : 'off'}`);
278
+ }
279
+
280
+ async function handleWorkspaceCommand(subcommand, rest) {
281
+ if (subcommand === 'list' || !subcommand) return printWorkspaces();
282
+ if (subcommand === 'add') return await addWorkspace(rest);
283
+ if (subcommand === 'remove') return await removeWorkspace(rest[0]);
284
+ if (subcommand === 'use') return await useWorkspace(rest[0]);
285
+ if (subcommand === 'describe') return await describeWorkspace(rest[0], rest.slice(1).join(' '));
286
+ if (subcommand === 'write') return await setWorkspaceAccess(rest[0], 'allowWrite', rest[1]);
287
+ if (subcommand === 'full-access')
288
+ return await setWorkspaceAccess(rest[0], 'allowFullAccess', rest[1]);
289
+ throw new Error('workspace commands: list, add, remove, use, describe, write, full-access');
290
+ }
291
+
292
+ async function addWorkspace(parts) {
293
+ const [alias, root, ...description] = parts;
294
+ assertAlias(alias);
295
+ if (!root) throw new Error('usage: workspace add <alias> <path> [description]');
296
+ upsertWorkspace(config.workspaces, {
297
+ alias,
298
+ root: resolveUserPath(root),
299
+ description: description.join(' ') || `${alias} workspace`,
300
+ allowWrite: config.writeTasksEnabled,
301
+ allowFullAccess: config.fullAccessEnabled,
302
+ defaultSandbox: sandboxFor(config.writeTasksEnabled, config.fullAccessEnabled),
303
+ });
304
+ await saveAndRestart(`workspace ${alias} saved`);
305
+ }
306
+
307
+ async function removeWorkspace(alias) {
308
+ assertAlias(alias);
309
+ if (config.workspaces.length === 1) throw new Error('cannot remove the last workspace');
310
+ findWorkspace(alias);
311
+ config.workspaces = config.workspaces.filter((workspace) => workspace.alias !== alias);
312
+ await saveAndRestart(`workspace ${alias} removed`);
313
+ }
314
+
315
+ async function useWorkspace(alias) {
316
+ assertAlias(alias);
317
+ const workspace = findWorkspace(alias);
318
+ config.workspaces = [workspace, ...config.workspaces.filter((item) => item.alias !== alias)];
319
+ await saveAndRestart(`active workspace ${alias}`);
320
+ }
321
+
322
+ async function describeWorkspace(alias, description) {
323
+ assertAlias(alias);
324
+ if (!description) throw new Error('usage: workspace describe <alias> <description>');
325
+ findWorkspace(alias).description = description;
326
+ await saveAndRestart(`workspace ${alias} described`);
327
+ }
328
+
329
+ async function setWorkspaceAccess(alias, key, raw) {
330
+ assertAlias(alias);
331
+ const workspace = findWorkspace(alias);
332
+ workspace[key] = parseOnOff(raw, !workspace[key]);
333
+ if (key === 'allowFullAccess' && workspace[key]) {
334
+ workspace.allowWrite = true;
335
+ config.writeTasksEnabled = true;
336
+ config.fullAccessEnabled = true;
337
+ }
338
+ if (key === 'allowWrite' && !workspace[key]) workspace.allowFullAccess = false;
339
+ syncWorkspaceSandbox(workspace);
340
+ await saveAndRestart(`workspace ${alias} updated`);
341
+ }
342
+
343
+ async function setPort(raw) {
344
+ if (!/^\d+$/.test(raw ?? '')) throw new Error('usage: port <number>');
345
+ config.port = String(Number(raw));
346
+ config.relayUrl = `ws://127.0.0.1:${config.port}/agent`;
347
+ await saveAndRestart(`port ${config.port}`);
348
+ }
349
+
350
+ async function rotateToken() {
351
+ config.relayToken = randomHex();
352
+ await saveAndRestart('token rotated');
353
+ }
354
+
355
+ async function setScalar(key, raw, label) {
356
+ if (!raw) throw new Error(`usage: ${label} <value>`);
357
+ config[key] = raw;
358
+ await saveAndRestart(`${label} ${raw}`);
359
+ }
360
+
361
+ async function setEnum(key, raw, allowed) {
362
+ if (!allowed.includes(raw)) throw new Error(`allowed values: ${allowed.join(', ')}`);
363
+ config[key] = raw;
364
+ await saveAndRestart(`${key} ${raw}`);
365
+ }
366
+
367
+ async function setCodex(parts) {
368
+ if (!parts.length) throw new Error('usage: codex <command> [app-server args...]');
369
+ config.appServerCommand = parts[0];
370
+ config.appServerArgs = parts.slice(1).length
371
+ ? parts.slice(1)
372
+ : ['app-server', '--listen', 'stdio://'];
373
+ await saveAndRestart(`codex command ${config.appServerCommand}`);
374
+ }
375
+
376
+ async function saveAndRestart(message) {
377
+ saveConfig();
378
+ console.log(`✅ ${message}. Saved.`);
379
+ await startStack();
380
+ }
381
+
382
+ function printStatus() {
383
+ console.log(`relay ${statusIcon(statuses.relay)} ${statuses.relay}`);
384
+ console.log(`agent ${statusIcon(statuses.agent)} ${statuses.agent}`);
385
+ console.log(`poke ${statusIcon(statuses.poke)} ${statuses.poke}`);
386
+ console.log(`mcp ${mcpHttpUrl()}`);
387
+ console.log(`mode ${modeLabel(activeWorkspace())}`);
388
+ console.log(`space ${activeWorkspace().alias} -> ${activeWorkspace().root}`);
389
+ console.log('tip type "help" for commands');
390
+ }
391
+
392
+ function printConfig() {
393
+ console.log(JSON.stringify(redactConfig(config), null, 2));
394
+ }
395
+
396
+ function printWorkspaces() {
397
+ for (const workspace of config.workspaces) {
398
+ const marker = workspace === activeWorkspace() ? '*' : ' ';
399
+ console.log(`${marker} ${workspace.alias} ${modeLabel(workspace)} ${workspace.root}`);
400
+ }
401
+ }
402
+
403
+ function printServiceOutput(name) {
404
+ const names = name ? [name] : ['relay', 'agent', 'poke'];
405
+ for (const key of names) {
406
+ const lines = serviceLogs.get(key);
407
+ if (!lines) {
408
+ console.log(`\n${serviceTitle(key)} output\nNo output yet.`);
409
+ continue;
410
+ }
411
+ console.log(`\n${serviceTitle(key)} output`);
412
+ console.log(lines.slice(-30).join('\n') || 'no output');
413
+ }
414
+ }
415
+
416
+ function printInteractiveHelp() {
417
+ console.log(`pokedex commands
418
+ status
419
+ config
420
+ output [relay|agent|poke]
421
+ write [on|off]
422
+ full-access [on|off]
423
+ workspace list
424
+ workspace add <alias> <path> [description]
425
+ workspace remove <alias>
426
+ workspace use <alias>
427
+ workspace describe <alias> <description>
428
+ workspace write <alias> [on|off]
429
+ workspace full-access <alias> [on|off]
430
+ model <name>
431
+ reasoning minimal|low|medium|high|xhigh
432
+ verbosity low|medium|high
433
+ approval untrusted|on-request|never
434
+ codex <command> [app-server args...]
435
+ port <number>
436
+ token rotate
437
+ restart
438
+ quit
439
+
440
+ setup
441
+ codex login
442
+ npx poke@latest login`);
443
+ }
444
+
445
+ function spawnManaged(name, bin, binArgs) {
446
+ const commandInfo = commandFor(bin);
447
+ const child = spawn(commandInfo.command, [...commandInfo.args, ...binArgs], {
448
+ cwd: invocationCwd,
449
+ stdio: ['ignore', 'pipe', 'pipe'],
450
+ env: process.env,
451
+ });
452
+ const entry = { child, exitCode: null, signal: null };
453
+ managedChildren.set(name, entry);
454
+ serviceLogs.set(name, []);
455
+ child.stdout?.setEncoding('utf8');
456
+ child.stderr?.setEncoding('utf8');
457
+ child.stdout?.on('data', (chunk) => appendLog(name, chunk));
458
+ child.stderr?.on('data', (chunk) => appendLog(name, chunk));
459
+ child.on('error', (error) => {
460
+ statuses[name] = 'error';
461
+ appendLog(name, error.message);
462
+ });
463
+ child.on('exit', (code, signal) => {
464
+ entry.exitCode = code ?? 0;
465
+ entry.signal = signal;
466
+ if (stopping || restarting) return;
467
+ statuses[name] = 'down';
468
+ if (!readline) return;
469
+ console.error(
470
+ `\n⚠️ ${serviceTitle(name)} stopped. Type "status" or "restart"; type "help" for commands.`
471
+ );
472
+ readline?.prompt();
473
+ });
474
+ }
475
+
476
+ function failIfServiceExited(name, detail) {
477
+ const entry = managedChildren.get(name);
478
+ if (!entry) throw serviceFailure(name, detail);
479
+ if (entry.child.exitCode !== null || entry.child.killed || statuses[name] === 'error') {
480
+ throw serviceFailure(name, detail);
481
+ }
482
+ }
483
+
484
+ function serviceFailure(name, detail) {
485
+ if (!name) return new Error(detail);
486
+ const entry = managedChildren.get(name);
487
+ const output = formatServiceOutput(name);
488
+ const lines = [`⚠️ ${serviceTitle(name)} needs attention.`, serviceHint(name, detail)];
489
+ if (
490
+ !entry ||
491
+ (entry.child.exitCode === null &&
492
+ !entry.child.killed &&
493
+ entry.exitCode === null &&
494
+ !entry.signal &&
495
+ statuses[name] !== 'error')
496
+ ) {
497
+ if (output) lines.push('', output);
498
+ lines.push('', 'Tip: type "help" after Pokedex starts to see commands.');
499
+ return new Error(lines.join('\n'));
500
+ }
501
+ if (output) lines.push('', output);
502
+ lines.push('', 'Tip: type "help" after Pokedex starts to see commands.');
503
+ return new Error(lines.join('\n'));
504
+ }
505
+
506
+ function formatServiceOutput(name) {
507
+ const lines = serviceLogs.get(name) ?? [];
508
+ return lines.length ? `${serviceTitle(name)} output:\n${lines.slice(-20).join('\n')}` : '';
509
+ }
510
+
511
+ function appendLog(name, chunk) {
512
+ const lines = serviceLogs.get(name) ?? [];
513
+ lines.push(
514
+ ...String(chunk)
515
+ .split(/\r?\n/)
516
+ .map((line) => line.trim())
517
+ .map((line) => cleanServiceLine(name, line))
518
+ .filter(Boolean)
519
+ );
520
+ serviceLogs.set(name, lines.slice(-100));
521
+ }
522
+
523
+ async function stopStack(final) {
524
+ restarting = !final;
525
+ for (const entry of managedChildren.values()) entry.child.kill('SIGTERM');
526
+ const children = [...managedChildren.values()].map((entry) => waitForExit(entry.child));
527
+ await Promise.all(children);
528
+ managedChildren.clear();
529
+ if (!final) {
530
+ restarting = false;
531
+ statuses.relay = 'idle';
532
+ statuses.agent = 'idle';
533
+ statuses.poke = 'idle';
534
+ }
535
+ }
536
+
537
+ async function stopManaged(code) {
538
+ if (stopping) return;
539
+ stopping = true;
540
+ readline?.close();
541
+ await stopStack(true);
542
+ process.exit(code);
543
+ }
544
+
545
+ function waitForExit(child) {
546
+ if (child.exitCode !== null) return Promise.resolve();
547
+ return new Promise((resolveExit) => child.once('exit', resolveExit));
548
+ }
549
+
550
+ async function fetchJson(url) {
551
+ try {
552
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
553
+ return response.ok ? await response.json() : null;
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+
559
+ function saveConfig() {
560
+ mkdirSync(dirname(configPath), { recursive: true });
561
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
562
+ }
563
+
564
+ function loadSavedConfig() {
565
+ if (existsSync(configPath)) return readJson(configPath);
566
+ return {};
567
+ }
568
+
569
+ function readJson(path) {
570
+ if (!existsSync(path)) return {};
571
+ try {
572
+ const state = JSON.parse(readFileSync(path, 'utf8'));
573
+ return state && typeof state === 'object' ? state : {};
574
+ } catch (error) {
575
+ die(`invalid json file ${path}: ${error.message}`);
576
+ }
577
+ }
578
+
579
+ function upsertWorkspace(workspaces, workspace) {
580
+ const existing = workspaces.findIndex((item) => item.alias === workspace.alias);
581
+ if (existing === -1) workspaces.unshift(workspace);
582
+ else workspaces[existing] = { ...workspaces[existing], ...workspace };
583
+ }
584
+
585
+ function firstWorkspace(raw) {
586
+ return Array.isArray(raw.workspaces) && raw.workspaces[0] ? raw.workspaces[0] : {};
587
+ }
588
+
589
+ function activeWorkspace() {
590
+ return config.workspaces[0];
591
+ }
592
+
593
+ function findWorkspace(alias) {
594
+ const workspace = config.workspaces.find((item) => item.alias === alias);
595
+ if (!workspace) throw new Error(`unknown workspace: ${alias}`);
596
+ return workspace;
597
+ }
598
+
599
+ function syncWorkspaceSandbox(workspace) {
600
+ workspace.defaultSandbox = sandboxFor(workspace.allowWrite, workspace.allowFullAccess);
601
+ }
602
+
603
+ function sandboxFor(writeEnabled, fullAccess) {
604
+ if (fullAccess) return 'danger_full_access';
605
+ if (writeEnabled) return 'workspace_write';
606
+ return 'read_only';
607
+ }
608
+
609
+ function modeLabel(workspace) {
610
+ if (workspace.allowFullAccess) return 'full-access';
611
+ if (workspace.allowWrite) return 'write';
612
+ return 'read-only';
613
+ }
614
+
615
+ function statusIcon(status) {
616
+ if (status === 'ok') return '✅';
617
+ if (status === 'starting') return '⏳';
618
+ if (status === 'error' || status === 'down') return '⚠️';
619
+ return '•';
620
+ }
621
+
622
+ function serviceTitle(name) {
623
+ if (name === 'relay') return 'pokedex relay';
624
+ if (name === 'agent') return 'Codex agent';
625
+ if (name === 'poke') return 'Poke tunnel';
626
+ return 'pokedex';
627
+ }
628
+
629
+ function serviceHint(name, detail) {
630
+ if (name === 'poke') return 'Run `npx poke@latest login`, then start Pokedex again.';
631
+ if (name === 'agent') return 'Run `codex login` and `codex doctor`, then start Pokedex again.';
632
+ if (name === 'relay')
633
+ return 'Check the port with `pokedex --port <number>`, then start Pokedex again.';
634
+ return detail;
635
+ }
636
+
637
+ function cleanServiceLine(name, line) {
638
+ if (name !== 'poke') return line;
639
+ return line.replace(/Run ['"]?poke login['"]?\.?/gi, 'Run npx poke@latest login.');
640
+ }
641
+
642
+ function formatError(error) {
643
+ const message = error instanceof Error ? error.message : String(error);
644
+ if (message.startsWith('⚠️') || message.startsWith('✅')) return message;
645
+ return `⚠️ ${message}\nType "help" for commands.`;
646
+ }
647
+
648
+ function parseOnOff(raw, defaultValue) {
649
+ if (!raw) return defaultValue;
650
+ if (['on', 'true', 'yes', '1'].includes(raw)) return true;
651
+ if (['off', 'false', 'no', '0'].includes(raw)) return false;
652
+ throw new Error('use on or off');
653
+ }
654
+
655
+ function assertAlias(alias) {
656
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(alias ?? ''))
657
+ throw new Error('alias must match /^[a-z0-9][a-z0-9_-]{0,63}$/i');
658
+ }
659
+
660
+ function splitCommand(line) {
661
+ const parts = [];
662
+ let current = '';
663
+ let quote = '';
664
+ for (const char of line) {
665
+ if (quote) {
666
+ if (char === quote) quote = '';
667
+ else current += char;
668
+ continue;
669
+ }
670
+ if (char === "'" || char === '"') {
671
+ quote = char;
672
+ continue;
673
+ }
674
+ if (/\s/.test(char)) {
675
+ if (current) parts.push(current);
676
+ current = '';
677
+ continue;
678
+ }
679
+ current += char;
680
+ }
681
+ if (current) parts.push(current);
682
+ return parts;
683
+ }
684
+
685
+ function redactConfig(raw) {
686
+ return { ...raw, relayToken: raw.relayToken ? `${raw.relayToken.slice(0, 6)}...` : '' };
687
+ }
688
+
689
+ function mcpHttpUrl() {
690
+ return `http://127.0.0.1:${config.port}/mcp`;
691
+ }
692
+
693
+ function mcpHttpUrlWithToken() {
694
+ return `${mcpHttpUrl()}?token=${encodeURIComponent(config.relayToken)}`;
695
+ }
696
+
697
+ function commandFor(bin) {
698
+ return resolveManagedCommand(bin) ?? { command: bin, args: [] };
699
+ }
700
+
701
+ function resolveManagedCommand(bin) {
702
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
703
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
704
+ const packagedPaths = {
705
+ 'pokedex-agent': join(packageRoot, 'dist/agent.cjs'),
706
+ 'pokedex-relay': join(packageRoot, 'dist/relay.cjs'),
707
+ };
708
+ const paths = {
709
+ 'pokedex-agent': join(root, 'apps/agent/dist/index.js'),
710
+ 'pokedex-relay': join(root, 'apps/relay/dist/index.js'),
711
+ };
712
+ if (existsSync(join(root, 'package.json'))) ensureLocalBuild(root, packagedPaths);
713
+ if (packagedPaths[bin] && existsSync(packagedPaths[bin]))
714
+ return { command: process.execPath, args: [packagedPaths[bin]] };
715
+ if (paths[bin] && existsSync(paths[bin]))
716
+ return { command: process.execPath, args: [paths[bin]] };
717
+ return null;
718
+ }
719
+
720
+ function ensureLocalBuild(root, packagedPaths) {
721
+ if (Object.values(packagedPaths).every((path) => existsSync(path))) return;
722
+ const result = spawnSync(npmBin(), ['run', 'build'], {
723
+ cwd: root,
724
+ encoding: 'utf8',
725
+ stdio: ['ignore', 'pipe', 'pipe'],
726
+ env: { ...process.env, npm_config_update_notifier: 'false' },
727
+ });
728
+ if (result.status !== 0)
729
+ throw new Error(`local build failed. run npm run build and retry.${formatBuildOutput(result)}`);
730
+ }
731
+
732
+ function formatBuildOutput(result) {
733
+ const output = [result.stdout, result.stderr]
734
+ .filter(Boolean)
735
+ .join('\n')
736
+ .split(/\r?\n/)
737
+ .map((line) => line.trim())
738
+ .filter(Boolean)
739
+ .slice(-30)
740
+ .join('\n');
741
+ return output ? `\n${output}` : '';
742
+ }
743
+
744
+ function npmBin() {
745
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
746
+ }
747
+
748
+ function npxBin() {
749
+ return process.platform === 'win32' ? 'npx.cmd' : 'npx';
750
+ }
751
+
752
+ function defaultConfigPath() {
753
+ return join(homedir(), '.pokedex', 'config.json');
754
+ }
755
+
756
+ function resolveUserPath(path) {
757
+ if (path === '~') return homedir();
758
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2));
759
+ return resolve(invocationCwd, path);
760
+ }
761
+
762
+ function value(flag) {
763
+ const index = args.indexOf(flag);
764
+ return index === -1 ? undefined : args[index + 1];
765
+ }
766
+
767
+ function has(flag) {
768
+ return args.includes(flag);
769
+ }
770
+
771
+ function randomHex() {
772
+ return randomBytes(32).toString('hex');
773
+ }
774
+
775
+ function registerSignals() {
776
+ process.once('SIGINT', () => void stopManaged(0));
777
+ process.once('SIGTERM', () => void stopManaged(0));
778
+ }
779
+
780
+ function printBanner() {
781
+ console.log(color(bannerText, '33;1'));
782
+ console.log('');
783
+ }
784
+
785
+ function color(text, code) {
786
+ if (!process.stdout.isTTY || process.env.NO_COLOR) return text;
787
+ return `\u001b[${code}m${text}\u001b[0m`;
788
+ }
789
+
790
+ function die(message) {
791
+ console.error(formatError(message));
792
+ process.exit(1);
793
+ }
794
+
795
+ function help() {
796
+ console.log(`pokedex
797
+ Local Poke to Codex bridge.
798
+
799
+ usage
800
+ pokedex [--workspace .] [--port 3000] [--write] [--read-only]
801
+ pokedex help
802
+
803
+ setup
804
+ codex login
805
+ npx poke@latest login
806
+
807
+ config
808
+ ~/.pokedex/config.json
809
+
810
+ interactive commands
811
+ status
812
+ output [relay|agent|poke]
813
+ write on
814
+ workspace add repo ./repo
815
+ model gpt-5.5
816
+ restart
817
+ help
818
+ quit
819
+
820
+ common
821
+ npx codex-to-poke
822
+ npx codex-to-poke --write
823
+ npx codex-to-poke --read-only
824
+ `);
825
+ }