@steipete/oracle 0.6.0 → 0.6.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.
@@ -183,7 +183,9 @@ async function pollAssistantCompletion(Runtime, timeoutMs) {
183
183
  isStopButtonVisible(Runtime),
184
184
  isCompletionVisible(Runtime),
185
185
  ]);
186
- if (completionVisible || (!stopVisible && stableCycles >= requiredStableCycles)) {
186
+ // Require at least 2 stable cycles even when completion buttons are visible
187
+ // to ensure DOM text has fully rendered (buttons can appear before text settles)
188
+ if ((completionVisible && stableCycles >= 2) || (!stopVisible && stableCycles >= requiredStableCycles)) {
187
189
  return normalized;
188
190
  }
189
191
  }
@@ -211,10 +213,36 @@ async function isCompletionVisible(Runtime) {
211
213
  try {
212
214
  const { result } = await Runtime.evaluate({
213
215
  expression: `(() => {
214
- if (document.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
216
+ // Find the LAST assistant turn to check completion status
217
+ // Must match the same logic as buildAssistantExtractor for consistency
218
+ const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
219
+ const isAssistantTurn = (node) => {
220
+ if (!(node instanceof HTMLElement)) return false;
221
+ const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
222
+ if (role === 'assistant') return true;
223
+ const testId = (node.getAttribute('data-testid') || '').toLowerCase();
224
+ if (testId.includes('assistant')) return true;
225
+ return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
226
+ };
227
+
228
+ const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
229
+ let lastAssistantTurn = null;
230
+ for (let i = turns.length - 1; i >= 0; i--) {
231
+ if (isAssistantTurn(turns[i])) {
232
+ lastAssistantTurn = turns[i];
233
+ break;
234
+ }
235
+ }
236
+ if (!lastAssistantTurn) {
237
+ return false;
238
+ }
239
+ // Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
240
+ if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
215
241
  return true;
216
242
  }
217
- return Array.from(document.querySelectorAll('.markdown')).some((n) => (n.textContent || '').trim() === 'Done');
243
+ // Also check for "Done" text in the last assistant turn's markdown
244
+ const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
245
+ return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
218
246
  })()`,
219
247
  returnByValue: true,
220
248
  });
@@ -257,12 +285,27 @@ function buildAssistantSnapshotExpression() {
257
285
  }
258
286
  function buildResponseObserverExpression(timeoutMs) {
259
287
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
288
+ const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
289
+ const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
260
290
  return `(() => {
261
291
  ${buildClickDispatcher()}
262
292
  const SELECTORS = ${selectorsLiteral};
263
293
  const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
264
294
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
295
+ const CONVERSATION_SELECTOR = ${conversationLiteral};
296
+ const ASSISTANT_SELECTOR = ${assistantLiteral};
265
297
  const settleDelayMs = 800;
298
+
299
+ // Helper to detect assistant turns - matches buildAssistantExtractor logic
300
+ const isAssistantTurn = (node) => {
301
+ if (!(node instanceof HTMLElement)) return false;
302
+ const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
303
+ if (role === 'assistant') return true;
304
+ const testId = (node.getAttribute('data-testid') || '').toLowerCase();
305
+ if (testId.includes('assistant')) return true;
306
+ return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
307
+ };
308
+
266
309
  ${buildAssistantExtractor('extractFromTurns')}
267
310
 
268
311
  const captureViaObserver = () =>
@@ -307,6 +350,24 @@ function buildResponseObserverExpression(timeoutMs) {
307
350
  }, ${timeoutMs});
308
351
  });
309
352
 
353
+ // Check if the last assistant turn has finished (scoped to avoid detecting old turns)
354
+ const isLastAssistantTurnFinished = () => {
355
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
356
+ let lastAssistantTurn = null;
357
+ for (let i = turns.length - 1; i >= 0; i--) {
358
+ if (isAssistantTurn(turns[i])) {
359
+ lastAssistantTurn = turns[i];
360
+ break;
361
+ }
362
+ }
363
+ if (!lastAssistantTurn) return false;
364
+ // Check for action buttons in this specific turn
365
+ if (lastAssistantTurn.querySelector(FINISHED_SELECTOR)) return true;
366
+ // Check for "Done" text in this turn's markdown
367
+ const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
368
+ return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
369
+ };
370
+
310
371
  const waitForSettle = async (snapshot) => {
311
372
  const settleWindowMs = 5000;
312
373
  const settleIntervalMs = 400;
@@ -321,9 +382,7 @@ function buildResponseObserverExpression(timeoutMs) {
321
382
  lastLength = refreshed.text?.length ?? lastLength;
322
383
  }
323
384
  const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
324
- const finishedVisible =
325
- Boolean(document.querySelector(FINISHED_SELECTOR)) ||
326
- Array.from(document.querySelectorAll('.markdown')).some((n) => (n.textContent || '').trim() === 'Done');
385
+ const finishedVisible = isLastAssistantTurnFinished();
327
386
 
328
387
  if (!stopVisible || finishedVisible) {
329
388
  break;
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = 'https://chatgpt.com/';
2
- export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.1';
2
+ export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.2';
3
3
  export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com', 'https://atlas.openai.com'];
4
4
  export const INPUT_SELECTORS = [
5
5
  'textarea[data-id="prompt-textarea"]',
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
1
+ import { mkdtemp, rm, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import net from 'node:net';
@@ -12,6 +12,7 @@ import { estimateTokenCount, withRetries, delay } from './utils.js';
12
12
  import { formatElapsed } from '../oracle/format.js';
13
13
  import { CHATGPT_URL } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
+ import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
15
16
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
16
17
  export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
17
18
  export async function runBrowserMode(options) {
@@ -98,6 +99,13 @@ export async function runBrowserMode(options) {
98
99
  remoteChrome: config.remoteChrome,
99
100
  }, userDataDir, logger));
100
101
  const chromeHost = chrome.host ?? '127.0.0.1';
102
+ // Persist profile state so future manual-login runs can reuse this Chrome.
103
+ if (manualLogin && chrome.port) {
104
+ await writeDevToolsActivePort(userDataDir, chrome.port);
105
+ if (!reusedChrome && chrome.pid) {
106
+ await writeChromePid(userDataDir, chrome.pid);
107
+ }
108
+ }
101
109
  let removeTerminationHooks = null;
102
110
  try {
103
111
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
@@ -533,57 +541,21 @@ async function maybeReuseRunningChrome(userDataDir, logger) {
533
541
  const port = await readDevToolsPort(userDataDir);
534
542
  if (!port)
535
543
  return null;
536
- const versionUrl = `http://127.0.0.1:${port}/json/version`;
537
- try {
538
- const controller = new AbortController();
539
- const timeout = setTimeout(() => controller.abort(), 1500);
540
- const response = await fetch(versionUrl, { signal: controller.signal });
541
- clearTimeout(timeout);
542
- if (!response.ok)
543
- throw new Error(`HTTP ${response.status}`);
544
- const pidPath = path.join(userDataDir, 'chrome.pid');
545
- let pid;
546
- try {
547
- const rawPid = (await readFile(pidPath, 'utf8')).trim();
548
- pid = Number.parseInt(rawPid, 10);
549
- if (Number.isNaN(pid))
550
- pid = undefined;
551
- }
552
- catch {
553
- pid = undefined;
554
- }
555
- logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
556
- return {
557
- port,
558
- pid,
559
- kill: async () => { },
560
- process: undefined,
561
- };
562
- }
563
- catch (error) {
564
- const message = error instanceof Error ? error.message : String(error);
565
- logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${message}); launching new Chrome.`);
544
+ const probe = await verifyDevToolsReachable({ port });
545
+ if (!probe.ok) {
546
+ logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
547
+ // Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
548
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'if_oracle_pid_dead' });
566
549
  return null;
567
550
  }
568
- }
569
- async function readDevToolsPort(userDataDir) {
570
- const candidates = [
571
- path.join(userDataDir, 'DevToolsActivePort'),
572
- path.join(userDataDir, 'Default', 'DevToolsActivePort'),
573
- ];
574
- for (const candidate of candidates) {
575
- try {
576
- const raw = await readFile(candidate, 'utf8');
577
- const firstLine = raw.split(/\r?\n/u)[0]?.trim();
578
- const port = Number.parseInt(firstLine ?? '', 10);
579
- if (Number.isFinite(port)) {
580
- return port;
581
- }
582
- }
583
- catch {
584
- }
585
- }
586
- return null;
551
+ const pid = await readChromePid(userDataDir);
552
+ logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
553
+ return {
554
+ port,
555
+ pid: pid ?? undefined,
556
+ kill: async () => { },
557
+ process: undefined,
558
+ };
587
559
  }
588
560
  async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
589
561
  const remoteChromeConfig = config.remoteChrome;
@@ -0,0 +1,171 @@
1
+ import path from 'node:path';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const DEVTOOLS_ACTIVE_PORT_FILENAME = 'DevToolsActivePort';
6
+ const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
7
+ DEVTOOLS_ACTIVE_PORT_FILENAME,
8
+ path.join('Default', DEVTOOLS_ACTIVE_PORT_FILENAME),
9
+ ];
10
+ const CHROME_PID_FILENAME = 'chrome.pid';
11
+ const execFileAsync = promisify(execFile);
12
+ export function getDevToolsActivePortPaths(userDataDir) {
13
+ return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
14
+ }
15
+ export async function readDevToolsPort(userDataDir) {
16
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
17
+ try {
18
+ const raw = await readFile(candidate, 'utf8');
19
+ const firstLine = raw.split(/\r?\n/u)[0]?.trim();
20
+ const port = Number.parseInt(firstLine ?? '', 10);
21
+ if (Number.isFinite(port)) {
22
+ return port;
23
+ }
24
+ }
25
+ catch {
26
+ // ignore missing/unreadable candidates
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ export async function writeDevToolsActivePort(userDataDir, port) {
32
+ const contents = `${port}\n/devtools/browser`;
33
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
34
+ try {
35
+ await mkdir(path.dirname(candidate), { recursive: true });
36
+ await writeFile(candidate, contents, 'utf8');
37
+ }
38
+ catch {
39
+ // best effort
40
+ }
41
+ }
42
+ }
43
+ export async function readChromePid(userDataDir) {
44
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
45
+ try {
46
+ const raw = (await readFile(pidPath, 'utf8')).trim();
47
+ const pid = Number.parseInt(raw, 10);
48
+ if (!Number.isFinite(pid) || pid <= 0) {
49
+ return null;
50
+ }
51
+ return pid;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export async function writeChromePid(userDataDir, pid) {
58
+ if (!Number.isFinite(pid) || pid <= 0)
59
+ return;
60
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
61
+ try {
62
+ await mkdir(path.dirname(pidPath), { recursive: true });
63
+ await writeFile(pidPath, `${Math.trunc(pid)}\n`, 'utf8');
64
+ }
65
+ catch {
66
+ // best effort
67
+ }
68
+ }
69
+ export function isProcessAlive(pid) {
70
+ if (!Number.isFinite(pid) || pid <= 0)
71
+ return false;
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ }
76
+ catch (error) {
77
+ // EPERM means "exists but no permission"; treat as alive.
78
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ }
84
+ export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attempts = 3, timeoutMs = 3000, }) {
85
+ const versionUrl = `http://${host}:${port}/json/version`;
86
+ for (let attempt = 0; attempt < attempts; attempt++) {
87
+ try {
88
+ const controller = new AbortController();
89
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
90
+ const response = await fetch(versionUrl, { signal: controller.signal });
91
+ clearTimeout(timeout);
92
+ if (!response.ok) {
93
+ throw new Error(`HTTP ${response.status}`);
94
+ }
95
+ return { ok: true };
96
+ }
97
+ catch (error) {
98
+ if (attempt < attempts - 1) {
99
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
100
+ continue;
101
+ }
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ return { ok: false, error: message };
104
+ }
105
+ }
106
+ return { ok: false, error: 'unreachable' };
107
+ }
108
+ export async function cleanupStaleProfileState(userDataDir, logger, options = {}) {
109
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
110
+ try {
111
+ await rm(candidate, { force: true });
112
+ logger?.(`Removed stale DevToolsActivePort: ${candidate}`);
113
+ }
114
+ catch {
115
+ // ignore cleanup errors
116
+ }
117
+ }
118
+ const lockRemovalMode = options.lockRemovalMode ?? 'never';
119
+ if (lockRemovalMode === 'never') {
120
+ return;
121
+ }
122
+ const pid = await readChromePid(userDataDir);
123
+ if (!pid) {
124
+ return;
125
+ }
126
+ if (isProcessAlive(pid)) {
127
+ logger?.(`Chrome pid ${pid} still alive; skipping profile lock cleanup`);
128
+ return;
129
+ }
130
+ // Extra safety: if Chrome is running with this profile (but with a different PID, e.g. user relaunched
131
+ // without remote debugging), never delete lock files.
132
+ if (await isChromeUsingUserDataDir(userDataDir)) {
133
+ logger?.('Detected running Chrome using this profile; skipping profile lock cleanup');
134
+ return;
135
+ }
136
+ const lockFiles = [
137
+ path.join(userDataDir, 'lockfile'),
138
+ path.join(userDataDir, 'SingletonLock'),
139
+ path.join(userDataDir, 'SingletonSocket'),
140
+ path.join(userDataDir, 'SingletonCookie'),
141
+ ];
142
+ for (const lock of lockFiles) {
143
+ await rm(lock, { force: true }).catch(() => undefined);
144
+ }
145
+ logger?.('Cleaned up stale Chrome profile locks');
146
+ }
147
+ async function isChromeUsingUserDataDir(userDataDir) {
148
+ if (process.platform === 'win32') {
149
+ // On Windows, lockfiles are typically held open and removal should fail anyway; avoid expensive process scans.
150
+ return false;
151
+ }
152
+ try {
153
+ const { stdout } = await execFileAsync('ps', ['-ax', '-o', 'command='], { maxBuffer: 10 * 1024 * 1024 });
154
+ const lines = String(stdout ?? '').split('\n');
155
+ const needle = userDataDir;
156
+ for (const line of lines) {
157
+ if (!line)
158
+ continue;
159
+ const lower = line.toLowerCase();
160
+ if (!lower.includes('chrome') && !lower.includes('chromium'))
161
+ continue;
162
+ if (line.includes(needle) && lower.includes('user-data-dir')) {
163
+ return true;
164
+ }
165
+ }
166
+ }
167
+ catch {
168
+ // best effort
169
+ }
170
+ return false;
171
+ }
@@ -18,7 +18,14 @@ function isProcessAlive(pid) {
18
18
  return true;
19
19
  }
20
20
  catch (error) {
21
- return !(error instanceof Error && error.code === 'ESRCH');
21
+ const code = error instanceof Error ? error.code : undefined;
22
+ if (code === 'ESRCH' || code === 'EINVAL') {
23
+ return false;
24
+ }
25
+ if (code === 'EPERM') {
26
+ return true;
27
+ }
28
+ return true;
22
29
  }
23
30
  }
24
31
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
@@ -360,11 +360,6 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
360
360
  }
361
361
  : undefined,
362
362
  });
363
- if (mode === 'browser') {
364
- log(dim('Next steps (browser fallback):')); // guides users when automation breaks
365
- log(dim('- Rerun with --engine api to bypass Chrome entirely.'));
366
- log(dim('- Or rerun with --engine api --render-markdown [--file …] to generate a single markdown bundle you can paste into ChatGPT manually (add --browser-bundle-files if you still want attachments).'));
367
- }
368
363
  if (modelForStatus) {
369
364
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
370
365
  status: 'error',
@@ -4,12 +4,12 @@ import path from 'node:path';
4
4
  import net from 'node:net';
5
5
  import { randomBytes, randomUUID } from 'node:crypto';
6
6
  import { spawn, spawnSync } from 'node:child_process';
7
- import { existsSync } from 'node:fs';
8
7
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
9
8
  import chalk from 'chalk';
10
9
  import { runBrowserMode } from '../browserMode.js';
11
10
  import { loadChromeCookies } from '../browser/chromeCookies.js';
12
11
  import { CHATGPT_URL } from '../browser/constants.js';
12
+ import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from '../browser/profileState.js';
13
13
  import { normalizeChatgptUrl } from '../browser/utils.js';
14
14
  async function findAvailablePort() {
15
15
  return await new Promise((resolve, reject) => {
@@ -209,10 +209,17 @@ export async function serveRemote(options = {}) {
209
209
  if (preferManualLogin) {
210
210
  await mkdir(manualProfileDir, { recursive: true });
211
211
  console.log(`Cookie extraction is unavailable on this platform. Using manual-login Chrome profile at ${manualProfileDir}. Remote runs will reuse this profile; sign in once when the browser opens.`);
212
- const devtoolsPortFile = path.join(manualProfileDir, 'DevToolsActivePort');
213
- const alreadyRunning = existsSync(devtoolsPortFile);
214
- if (alreadyRunning) {
215
- console.log('Detected an existing automation Chrome session; will reuse it for manual login.');
212
+ const existingPort = await readDevToolsPort(manualProfileDir);
213
+ if (existingPort) {
214
+ const reachable = await verifyDevToolsReachable({ port: existingPort });
215
+ if (reachable.ok) {
216
+ console.log('Detected an existing automation Chrome session; will reuse it for manual login.');
217
+ }
218
+ else {
219
+ console.log(`Found stale DevToolsActivePort (port ${existingPort}, ${reachable.error}); launching a fresh manual-login Chrome.`);
220
+ await cleanupStaleProfileState(manualProfileDir, console.log, { lockRemovalMode: 'never' });
221
+ void launchManualLoginChrome(manualProfileDir, CHATGPT_URL, console.log);
222
+ }
216
223
  }
217
224
  else {
218
225
  void launchManualLoginChrome(manualProfileDir, CHATGPT_URL, console.log);
@@ -459,12 +466,11 @@ async function launchManualLoginChrome(profileDir, url, logger) {
459
466
  });
460
467
  const chosenPort = chrome?.port ?? debugPort ?? null;
461
468
  if (chosenPort) {
462
- // Write DevToolsActivePort eagerly so maybeReuseRunningChrome can attach on the next run
463
- const devtoolsFile = path.join(profileDir, 'DevToolsActivePort');
464
- const devtoolsFileDefault = path.join(profileDir, 'Default', 'DevToolsActivePort');
465
- const contents = `${chosenPort}\n/devtools/browser`;
466
- await writeFile(devtoolsFile, contents).catch(() => undefined);
467
- await writeFile(devtoolsFileDefault, contents).catch(() => undefined);
469
+ // Persist DevToolsActivePort eagerly so future runs can attach/reuse this Chrome.
470
+ await writeDevToolsActivePort(profileDir, chosenPort);
471
+ if (chrome?.pid) {
472
+ await writeChromePid(profileDir, chrome.pid);
473
+ }
468
474
  logger(`Manual-login Chrome DevTools port: ${chosenPort}`);
469
475
  logger(`If needed, DevTools JSON at http://127.0.0.1:${chosenPort}/json/version`);
470
476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",