@steipete/oracle 0.6.1 → 0.7.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/README.md CHANGED
@@ -21,26 +21,29 @@ Use `npx -y @steipete/oracle …` (not `pnpx`)—pnpx's sandboxed cache can’t
21
21
 
22
22
  ```bash
23
23
  # Copy the bundle and paste into ChatGPT
24
- npx @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"
24
+ npx -y @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"
25
25
 
26
26
  # Minimal API run (expects OPENAI_API_KEY in your env)
27
- npx @steipete/oracle -p "Write a concise architecture note for the storage adapters" --file src/storage/README.md
27
+ npx -y @steipete/oracle -p "Write a concise architecture note for the storage adapters" --file src/storage/README.md
28
28
 
29
29
  # Multi-model API run
30
- npx @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.1-pro,gemini-3-pro --file "src/**/*.ts"
30
+ npx -y @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.1-pro,gemini-3-pro --file "src/**/*.ts"
31
31
 
32
32
  # Preview without spending tokens
33
- npx @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
33
+ npx -y @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
34
34
 
35
35
  # Browser run (no API key, will open ChatGPT)
36
- npx @steipete/oracle --engine browser -p "Walk through the UI smoke test" --file "src/**/*.ts"
36
+ npx -y @steipete/oracle --engine browser -p "Walk through the UI smoke test" --file "src/**/*.ts"
37
+
38
+ # Gemini browser mode (no API key; uses Chrome cookies from gemini.google.com)
39
+ npx -y @steipete/oracle --engine browser --model gemini-3-pro --prompt "a cute robot holding a banana" --generate-image out.jpg --aspect 1:1
37
40
 
38
41
  # Sessions (list and replay)
39
- npx @steipete/oracle status --hours 72
40
- npx @steipete/oracle session <id> --render
42
+ npx -y @steipete/oracle status --hours 72
43
+ npx -y @steipete/oracle session <id> --render
41
44
 
42
45
  # TUI (interactive, only for humans)
43
- npx @steipete/oracle tui
46
+ npx -y @steipete/oracle tui
44
47
  ```
45
48
 
46
49
  Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.
@@ -49,6 +52,8 @@ Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser i
49
52
 
50
53
  **CLI**
51
54
  - API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
55
+ - Gemini browser mode uses Chrome cookies instead of an API key—just be logged into `gemini.google.com` in Chrome (no Python/venv required).
56
+ - If your Gemini account can’t access “Pro”, Oracle auto-falls back to a supported model for web runs (and logs the fallback in verbose mode).
52
57
  - Prefer API mode or `--copy` + manual paste; browser automation is experimental.
53
58
  - Browser support: stable on macOS; works on Linux (add `--browser-chrome-path/--browser-cookie-path` when needed) and Windows (manual-login or inline cookies recommended when app-bound cookies block decryption).
54
59
  - Remote browser service: `oracle serve` on a signed-in host; clients use `--remote-host/--remote-token`.
@@ -109,6 +114,9 @@ npx -y @steipete/oracle oracle-mcp
109
114
  | `--dry-run [summary\|json\|full]` | Preview without sending. |
110
115
  | `--remote-host`, `--remote-token` | Use a remote `oracle serve` host (browser). |
111
116
  | `--remote-chrome <host:port>` | Attach to an existing remote Chrome session (browser). |
117
+ | `--youtube <url>` | YouTube video URL to analyze (Gemini browser mode). |
118
+ | `--generate-image <file>` | Generate image and save to file (Gemini browser mode). |
119
+ | `--edit-image <file>` | Edit existing image with `--output` (Gemini browser mode). |
112
120
  | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version` | Target Azure OpenAI endpoints (picks Azure client automatically). |
113
121
 
114
122
  ## Configuration
@@ -18,6 +18,7 @@ import { DEFAULT_MODEL, MODEL_CONFIGS, readFiles, estimateRequestTokens, buildRe
18
18
  import { isKnownModel } from '../src/oracle/modelResolver.js';
19
19
  import { CHATGPT_URL } from '../src/browserMode.js';
20
20
  import { createRemoteBrowserExecutor } from '../src/remote/client.js';
21
+ import { createGeminiWebExecutor } from '../src/gemini-web/index.js';
21
22
  import { applyHelpStyling } from '../src/cli/help.js';
22
23
  import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, } from '../src/cli/options.js';
23
24
  import { copyToClipboard } from '../src/cli/clipboard.js';
@@ -26,6 +27,7 @@ import { shouldDetachSession } from '../src/cli/detach.js';
26
27
  import { applyHiddenAliases } from '../src/cli/hiddenAliases.js';
27
28
  import { buildBrowserConfig, resolveBrowserModelLabel } from '../src/cli/browserConfig.js';
28
29
  import { performSessionRun } from '../src/cli/sessionRunner.js';
30
+ import { isMediaFile } from '../src/browser/prompt.js';
29
31
  import { attachSession, showStatus, formatCompletionSummary } from '../src/cli/sessionDisplay.js';
30
32
  import { formatCompactNumber } from '../src/cli/format.js';
31
33
  import { formatIntroLine } from '../src/cli/tagline.js';
@@ -114,7 +116,7 @@ program
114
116
  .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.1-pro,gemini-3-pro").')
115
117
  .argParser(collectModelList)
116
118
  .default([]))
117
- .addOption(new Option('-e, --engine <mode>', 'Execution engine (api | browser). Engine is preferred; --mode is a legacy alias. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.').choices(['api', 'browser']))
119
+ .addOption(new Option('-e, --engine <mode>', 'Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.').choices(['api', 'browser']))
118
120
  .addOption(new Option('--mode <mode>', 'Alias for --engine (api | browser).').choices(['api', 'browser']).hideHelp())
119
121
  .option('--files-report', 'Show token usage per attached file (also prints automatically when files exceed the token budget).', false)
120
122
  .option('-v, --verbose', 'Enable verbose logging for all operations.', false)
@@ -182,6 +184,12 @@ program
182
184
  .addOption(new Option('--remote-token <token>', 'Access token for the remote `oracle serve` instance.'))
183
185
  .addOption(new Option('--browser-inline-files', 'Alias for --browser-attachments never (force pasting file contents inline).').default(false))
184
186
  .addOption(new Option('--browser-bundle-files', 'Bundle all attachments into a single archive before uploading.').default(false))
187
+ .addOption(new Option('--youtube <url>', 'YouTube video URL to analyze (Gemini web/cookie mode only; uses your signed-in Chrome cookies for gemini.google.com).'))
188
+ .addOption(new Option('--generate-image <file>', 'Generate image and save to file (Gemini web/cookie mode only; requires gemini.google.com Chrome cookies).'))
189
+ .addOption(new Option('--edit-image <file>', 'Edit existing image (use with --output, Gemini web/cookie mode only).'))
190
+ .addOption(new Option('--output <file>', 'Output file path for image operations (Gemini web/cookie mode only).'))
191
+ .addOption(new Option('--aspect <ratio>', 'Aspect ratio for image generation: 16:9, 1:1, 4:3, 3:4 (Gemini web/cookie mode only).'))
192
+ .addOption(new Option('--gemini-show-thoughts', 'Display Gemini thinking process (Gemini web/cookie mode only).').default(false))
185
193
  .option('--retain-hours <hours>', 'Prune stored sessions older than this many hours before running (set 0 to disable).', parseFloatOption)
186
194
  .option('--force', 'Force start a new session even if an identical prompt is already running.', false)
187
195
  .option('--debug-help', 'Show the advanced/debug option set and exit.', false)
@@ -512,18 +520,13 @@ async function runRootCommand(options) {
512
520
  const isCodex = primaryModelCandidate.startsWith('gpt-5.1-codex');
513
521
  const isClaude = primaryModelCandidate.startsWith('claude');
514
522
  const userForcedBrowser = options.browser || options.engine === 'browser';
515
- const hasNonGptBrowserTarget = (engine === 'browser' || userForcedBrowser) &&
523
+ const isBrowserCompatible = (model) => model.startsWith('gpt-') || model.startsWith('gemini');
524
+ const hasNonBrowserCompatibleTarget = (engine === 'browser' || userForcedBrowser) &&
516
525
  (normalizedMultiModels.length > 0
517
- ? normalizedMultiModels.some((model) => !model.startsWith('gpt-'))
518
- : !resolvedModelCandidate.startsWith('gpt-'));
519
- if (hasNonGptBrowserTarget) {
520
- throw new Error('Browser engine only supports GPT-series ChatGPT models. Re-run with --engine api for Grok, Claude, Gemini, or other non-GPT models.');
521
- }
522
- if (isGemini && userForcedBrowser) {
523
- throw new Error('Gemini is only supported via API. Use --engine api.');
524
- }
525
- if (isGemini && engine === 'browser') {
526
- engine = 'api';
526
+ ? normalizedMultiModels.some((model) => !isBrowserCompatible(model))
527
+ : !isBrowserCompatible(resolvedModelCandidate));
528
+ if (hasNonBrowserCompatibleTarget) {
529
+ throw new Error('Browser engine only supports GPT and Gemini models. Re-run with --engine api for Grok, Claude, or other models.');
527
530
  }
528
531
  if (isClaude && engine === 'browser') {
529
532
  console.log(chalk.dim('Browser engine is not supported for Claude models; switching to API.'));
@@ -672,7 +675,11 @@ async function runRootCommand(options) {
672
675
  return;
673
676
  }
674
677
  if (options.file && options.file.length > 0) {
675
- await readFiles(options.file, { cwd: process.cwd() });
678
+ const isBrowserMode = engine === 'browser' || userForcedBrowser;
679
+ const filesToValidate = isBrowserMode ? options.file.filter((f) => !isMediaFile(f)) : options.file;
680
+ if (filesToValidate.length > 0) {
681
+ await readFiles(filesToValidate, { cwd: process.cwd() });
682
+ }
676
683
  }
677
684
  const getSource = (key) => program.getOptionValueSource?.(key) ?? undefined;
678
685
  applyBrowserDefaultsFromConfig(options, userConfig, getSource);
@@ -698,6 +705,19 @@ async function runRootCommand(options) {
698
705
  };
699
706
  console.log(chalk.dim(`Routing browser automation to remote host ${remoteHost}`));
700
707
  }
708
+ else if (browserConfig && resolvedModel.startsWith('gemini')) {
709
+ browserDeps = {
710
+ executeBrowser: createGeminiWebExecutor({
711
+ youtube: options.youtube,
712
+ generateImage: options.generateImage,
713
+ editImage: options.editImage,
714
+ outputPath: options.output,
715
+ aspectRatio: options.aspect,
716
+ showThoughts: options.geminiShowThoughts,
717
+ }),
718
+ };
719
+ console.log(chalk.dim('Using Gemini web client for browser automation'));
720
+ }
701
721
  const remoteExecutionActive = Boolean(browserDeps);
702
722
  if (options.dryRun) {
703
723
  const baseRunOptions = buildRunOptions(resolvedOptions, {
@@ -6,10 +6,32 @@ import { isKnownModel } from '../oracle/modelResolver.js';
6
6
  import { buildPromptMarkdown } from '../oracle/promptAssembly.js';
7
7
  import { buildAttachmentPlan } from './policies.js';
8
8
  const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
9
+ const MEDIA_EXTENSIONS = new Set([
10
+ '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v',
11
+ '.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a',
12
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif',
13
+ '.pdf',
14
+ ]);
15
+ export function isMediaFile(filePath) {
16
+ const ext = path.extname(filePath).toLowerCase();
17
+ return MEDIA_EXTENSIONS.has(ext);
18
+ }
9
19
  export async function assembleBrowserPrompt(runOptions, deps = {}) {
10
20
  const cwd = deps.cwd ?? process.cwd();
11
21
  const readFilesFn = deps.readFilesImpl ?? readFiles;
12
- const files = await readFilesFn(runOptions.file ?? [], { cwd });
22
+ const allFilePaths = runOptions.file ?? [];
23
+ const textFilePaths = allFilePaths.filter((f) => !isMediaFile(f));
24
+ const mediaFilePaths = allFilePaths.filter((f) => isMediaFile(f));
25
+ const mediaAttachments = await Promise.all(mediaFilePaths.map(async (filePath) => {
26
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
27
+ const stats = await fs.stat(resolvedPath);
28
+ return {
29
+ path: resolvedPath,
30
+ displayPath: path.relative(cwd, resolvedPath) || path.basename(resolvedPath),
31
+ sizeBytes: stats.size,
32
+ };
33
+ }));
34
+ const files = await readFilesFn(textFilePaths, { cwd });
13
35
  const basePrompt = (runOptions.prompt ?? '').trim();
14
36
  const userPrompt = basePrompt;
15
37
  const systemPrompt = runOptions.system?.trim() || '';
@@ -40,9 +62,10 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
40
62
  .filter(Boolean)
41
63
  .join('\n\n')
42
64
  .trim();
43
- const attachments = selectedPlan.attachments.slice();
65
+ const attachments = [...selectedPlan.attachments, ...mediaAttachments];
44
66
  const shouldBundle = selectedPlan.shouldBundle;
45
67
  let bundleText = null;
68
+ let bundled = null;
46
69
  if (shouldBundle) {
47
70
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
48
71
  const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
@@ -59,6 +82,8 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
59
82
  displayPath: bundlePath,
60
83
  sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
61
84
  });
85
+ attachments.push(...mediaAttachments);
86
+ bundled = { originalCount: sections.length, bundlePath };
62
87
  }
63
88
  const inlineFileCount = selectedPlan.inlineFileCount;
64
89
  const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
@@ -85,7 +110,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
85
110
  let fallback = null;
86
111
  if (attachmentsPolicy === 'auto' && selectedPlan.mode === 'inline' && sections.length > 0) {
87
112
  const fallbackComposerText = baseComposerSections.join('\n\n').trim();
88
- const fallbackAttachments = uploadPlan.attachments.slice();
113
+ const fallbackAttachments = [...uploadPlan.attachments, ...mediaAttachments];
89
114
  let fallbackBundled = null;
90
115
  if (uploadPlan.shouldBundle) {
91
116
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
@@ -103,6 +128,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
103
128
  displayPath: bundlePath,
104
129
  sizeBytes: Buffer.byteLength(fallbackBundleText, 'utf8'),
105
130
  });
131
+ fallbackAttachments.push(...mediaAttachments);
106
132
  fallbackBundled = { originalCount: sections.length, bundlePath };
107
133
  }
108
134
  fallback = {
@@ -121,8 +147,6 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
121
147
  attachmentsPolicy,
122
148
  attachmentMode: selectedPlan.mode,
123
149
  fallback,
124
- bundled: shouldBundle && attachments.length === 1 && attachments[0]?.displayPath
125
- ? { originalCount: sections.length, bundlePath: attachments[0].displayPath }
126
- : null,
150
+ bundled,
127
151
  };
128
152
  }
@@ -5,11 +5,6 @@ import { runBrowserMode } from '../browserMode.js';
5
5
  import { assembleBrowserPrompt } from './prompt.js';
6
6
  import { BrowserAutomationError } from '../oracle/errors.js';
7
7
  export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, deps = {}) {
8
- if (runOptions.model.startsWith('gemini')) {
9
- throw new BrowserAutomationError('Gemini models are not available in browser mode. Re-run with --engine api.', {
10
- stage: 'preflight',
11
- });
12
- }
13
8
  const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
14
9
  const executeBrowser = deps.executeBrowser ?? runBrowserMode;
15
10
  const promptArtifacts = await assemblePrompt(runOptions, { cwd });
@@ -13,7 +13,6 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
13
13
  const resolvedModel = resolvedEngine === 'browser' && normalizedRequestedModels.length === 0
14
14
  ? inferModelFromLabel(cliModelArg)
15
15
  : resolveApiModel(cliModelArg);
16
- const isGemini = resolvedModel.startsWith('gemini');
17
16
  const isCodex = resolvedModel.startsWith('gpt-5.1-codex');
18
17
  const isClaude = resolvedModel.startsWith('claude');
19
18
  const isGrok = resolvedModel.startsWith('grok');
@@ -21,13 +20,13 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
21
20
  const allModels = normalizedRequestedModels.length > 0
22
21
  ? Array.from(new Set(normalizedRequestedModels.map((entry) => resolveApiModel(entry))))
23
22
  : [resolvedModel];
24
- const hasNonGptBrowserTarget = (browserRequested || browserConfigured) && allModels.some((m) => !m.startsWith('gpt-'));
25
- if (hasNonGptBrowserTarget) {
26
- throw new PromptValidationError('Browser engine only supports GPT-series ChatGPT models. Re-run with --engine api for Grok, Claude, Gemini, or other non-GPT models.', { engine: 'browser', models: allModels });
23
+ const isBrowserCompatible = (m) => m.startsWith('gpt-') || m.startsWith('gemini');
24
+ const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) && allModels.some((m) => !isBrowserCompatible(m));
25
+ if (hasNonBrowserCompatibleTarget) {
26
+ throw new PromptValidationError('Browser engine only supports GPT and Gemini models. Re-run with --engine api for Grok, Claude, or other models.', { engine: 'browser', models: allModels });
27
27
  }
28
- const engineCoercedToApi = engineWasBrowser && (isGemini || isCodex || isClaude || isGrok);
29
- // When Gemini, Claude, or Grok is selected, force API engine for auto-browser detection; codex also forces API.
30
- const fixedEngine = isGemini || isCodex || isClaude || isGrok || normalizedRequestedModels.length > 0 ? 'api' : resolvedEngine;
28
+ const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok);
29
+ const fixedEngine = isCodex || isClaude || isGrok || normalizedRequestedModels.length > 0 ? 'api' : resolvedEngine;
31
30
  const promptWithSuffix = userConfig?.promptSuffix && userConfig.promptSuffix.trim().length > 0
32
31
  ? `${prompt.trim()}\n${userConfig.promptSuffix}`
33
32
  : prompt;
@@ -38,9 +38,6 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
38
38
  const modelForStatus = runOptions.model ?? sessionMeta.model;
39
39
  try {
40
40
  if (mode === 'browser') {
41
- if (runOptions.model.startsWith('gemini')) {
42
- throw new Error('Gemini models are not available in browser mode. Re-run with --engine api.');
43
- }
44
41
  if (!browserConfig) {
45
42
  throw new Error('Missing browser configuration for session.');
46
43
  }
@@ -0,0 +1,322 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
4
+ const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb';
5
+ const MODEL_HEADERS = {
6
+ 'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
7
+ 'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
8
+ 'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
9
+ };
10
+ const GEMINI_APP_URL = 'https://gemini.google.com/app';
11
+ const GEMINI_STREAM_GENERATE_URL = 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate';
12
+ const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload';
13
+ const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz';
14
+ function getNestedValue(value, pathParts, fallback) {
15
+ let current = value;
16
+ for (const part of pathParts) {
17
+ if (current == null)
18
+ return fallback;
19
+ if (typeof part === 'number') {
20
+ if (!Array.isArray(current))
21
+ return fallback;
22
+ current = current[part];
23
+ }
24
+ else {
25
+ if (typeof current !== 'object')
26
+ return fallback;
27
+ current = current[part];
28
+ }
29
+ }
30
+ return current ?? fallback;
31
+ }
32
+ function buildCookieHeader(cookieMap) {
33
+ return Object.entries(cookieMap)
34
+ .filter(([, value]) => typeof value === 'string' && value.length > 0)
35
+ .map(([name, value]) => `${name}=${value}`)
36
+ .join('; ');
37
+ }
38
+ export async function fetchGeminiAccessToken(cookieMap) {
39
+ const cookieHeader = buildCookieHeader(cookieMap);
40
+ const res = await fetch(GEMINI_APP_URL, {
41
+ redirect: 'follow',
42
+ headers: {
43
+ cookie: cookieHeader,
44
+ 'user-agent': USER_AGENT,
45
+ },
46
+ });
47
+ const html = await res.text();
48
+ const tokens = ['SNlM0e', 'thykhd'];
49
+ for (const key of tokens) {
50
+ const match = html.match(new RegExp(`"${key}":"(.*?)"`));
51
+ if (match?.[1])
52
+ return match[1];
53
+ }
54
+ throw new Error('Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).');
55
+ }
56
+ function trimGeminiJsonEnvelope(text) {
57
+ const start = text.indexOf('[');
58
+ const end = text.lastIndexOf(']');
59
+ if (start === -1 || end === -1 || end <= start) {
60
+ throw new Error('Gemini response did not contain a JSON payload.');
61
+ }
62
+ return text.slice(start, end + 1);
63
+ }
64
+ function extractErrorCode(responseJson) {
65
+ const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
66
+ return typeof code === 'number' && code >= 0 ? code : undefined;
67
+ }
68
+ function extractGgdlUrls(rawText) {
69
+ const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
70
+ const seen = new Set();
71
+ const urls = [];
72
+ for (const match of matches) {
73
+ if (seen.has(match))
74
+ continue;
75
+ seen.add(match);
76
+ urls.push(match);
77
+ }
78
+ return urls;
79
+ }
80
+ function ensureFullSizeImageUrl(url) {
81
+ if (url.includes('=s2048'))
82
+ return url;
83
+ if (url.includes('=s'))
84
+ return url;
85
+ return `${url}=s2048`;
86
+ }
87
+ async function fetchWithCookiePreservingRedirects(url, init, maxRedirects = 10) {
88
+ let current = url;
89
+ for (let i = 0; i <= maxRedirects; i += 1) {
90
+ const res = await fetch(current, { ...init, redirect: 'manual' });
91
+ if (res.status >= 300 && res.status < 400) {
92
+ const location = res.headers.get('location');
93
+ if (!location)
94
+ return res;
95
+ current = new URL(location, current).toString();
96
+ continue;
97
+ }
98
+ return res;
99
+ }
100
+ throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
101
+ }
102
+ async function downloadGeminiImage(url, cookieMap, outputPath) {
103
+ const cookieHeader = buildCookieHeader(cookieMap);
104
+ const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
105
+ headers: {
106
+ cookie: cookieHeader,
107
+ 'user-agent': USER_AGENT,
108
+ },
109
+ });
110
+ if (!res.ok) {
111
+ throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
112
+ }
113
+ const data = new Uint8Array(await res.arrayBuffer());
114
+ await mkdir(path.dirname(outputPath), { recursive: true });
115
+ await writeFile(outputPath, data);
116
+ }
117
+ async function uploadGeminiFile(filePath) {
118
+ const absPath = path.resolve(process.cwd(), filePath);
119
+ const data = await readFile(absPath);
120
+ const fileName = path.basename(absPath);
121
+ const form = new FormData();
122
+ form.append('file', new Blob([data]), fileName);
123
+ const res = await fetch(GEMINI_UPLOAD_URL, {
124
+ method: 'POST',
125
+ redirect: 'follow',
126
+ headers: {
127
+ 'push-id': GEMINI_UPLOAD_PUSH_ID,
128
+ 'user-agent': USER_AGENT,
129
+ },
130
+ body: form,
131
+ });
132
+ const text = await res.text();
133
+ if (!res.ok) {
134
+ throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
135
+ }
136
+ return { id: text, name: fileName };
137
+ }
138
+ function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
139
+ const promptPayload = uploaded.length > 0
140
+ ? [
141
+ prompt,
142
+ 0,
143
+ null,
144
+ // Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
145
+ // Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
146
+ uploaded.map((file) => [[file.id, 1]]),
147
+ ]
148
+ : [prompt];
149
+ const innerList = [promptPayload, null, chatMetadata ?? null];
150
+ return JSON.stringify([null, JSON.stringify(innerList)]);
151
+ }
152
+ export function parseGeminiStreamGenerateResponse(rawText) {
153
+ const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
154
+ const errorCode = extractErrorCode(responseJson);
155
+ const parts = Array.isArray(responseJson) ? responseJson : [];
156
+ let bodyIndex = 0;
157
+ let body = null;
158
+ for (let i = 0; i < parts.length; i += 1) {
159
+ const partBody = getNestedValue(parts[i], [2], null);
160
+ if (!partBody)
161
+ continue;
162
+ try {
163
+ const parsed = JSON.parse(partBody);
164
+ const candidateList = getNestedValue(parsed, [4], []);
165
+ if (Array.isArray(candidateList) && candidateList.length > 0) {
166
+ bodyIndex = i;
167
+ body = parsed;
168
+ break;
169
+ }
170
+ }
171
+ catch {
172
+ // ignore
173
+ }
174
+ }
175
+ const candidateList = getNestedValue(body, [4], []);
176
+ const firstCandidate = candidateList[0];
177
+ const textRaw = getNestedValue(firstCandidate, [1, 0], '');
178
+ const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
179
+ const text = cardContent
180
+ ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
181
+ : textRaw;
182
+ const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
183
+ const metadata = getNestedValue(body, [1], []);
184
+ const images = [];
185
+ const webImages = getNestedValue(firstCandidate, [12, 1], []);
186
+ for (const webImage of webImages) {
187
+ const url = getNestedValue(webImage, [0, 0, 0], null);
188
+ if (!url)
189
+ continue;
190
+ images.push({
191
+ kind: 'web',
192
+ url,
193
+ title: getNestedValue(webImage, [7, 0], undefined),
194
+ alt: getNestedValue(webImage, [0, 4], undefined),
195
+ });
196
+ }
197
+ const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
198
+ if (hasGenerated) {
199
+ let imgBody = null;
200
+ for (let i = bodyIndex; i < parts.length; i += 1) {
201
+ const partBody = getNestedValue(parts[i], [2], null);
202
+ if (!partBody)
203
+ continue;
204
+ try {
205
+ const parsed = JSON.parse(partBody);
206
+ const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
207
+ if (candidateImages != null) {
208
+ imgBody = parsed;
209
+ break;
210
+ }
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ }
216
+ const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
217
+ const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
218
+ for (const genImage of generated) {
219
+ const url = getNestedValue(genImage, [0, 3, 3], null);
220
+ if (!url)
221
+ continue;
222
+ images.push({
223
+ kind: 'generated',
224
+ url,
225
+ title: '[Generated Image]',
226
+ alt: '',
227
+ });
228
+ }
229
+ }
230
+ return { metadata, text, thoughts, images, errorCode };
231
+ }
232
+ export function isGeminiModelUnavailable(errorCode) {
233
+ return errorCode === 1052;
234
+ }
235
+ export async function runGeminiWebOnce(input) {
236
+ const cookieHeader = buildCookieHeader(input.cookieMap);
237
+ const at = await fetchGeminiAccessToken(input.cookieMap);
238
+ const uploaded = [];
239
+ for (const file of input.files ?? []) {
240
+ uploaded.push(await uploadGeminiFile(file));
241
+ }
242
+ const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
243
+ const params = new URLSearchParams();
244
+ params.set('at', at);
245
+ params.set('f.req', fReq);
246
+ const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
247
+ method: 'POST',
248
+ redirect: 'follow',
249
+ headers: {
250
+ 'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
251
+ origin: 'https://gemini.google.com',
252
+ referer: 'https://gemini.google.com/',
253
+ 'x-same-domain': '1',
254
+ 'user-agent': USER_AGENT,
255
+ cookie: cookieHeader,
256
+ [MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
257
+ },
258
+ body: params.toString(),
259
+ });
260
+ const rawResponseText = await res.text();
261
+ if (!res.ok) {
262
+ return {
263
+ rawResponseText,
264
+ text: '',
265
+ thoughts: null,
266
+ metadata: input.chatMetadata ?? null,
267
+ images: [],
268
+ errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
269
+ };
270
+ }
271
+ try {
272
+ const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
273
+ return {
274
+ rawResponseText,
275
+ text: parsed.text ?? '',
276
+ thoughts: parsed.thoughts,
277
+ metadata: parsed.metadata,
278
+ images: parsed.images,
279
+ errorCode: parsed.errorCode,
280
+ };
281
+ }
282
+ catch (error) {
283
+ let responseJson = null;
284
+ try {
285
+ responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
286
+ }
287
+ catch {
288
+ responseJson = null;
289
+ }
290
+ const errorCode = extractErrorCode(responseJson);
291
+ return {
292
+ rawResponseText,
293
+ text: '',
294
+ thoughts: null,
295
+ metadata: input.chatMetadata ?? null,
296
+ images: [],
297
+ errorCode: typeof errorCode === 'number' ? errorCode : undefined,
298
+ errorMessage: error instanceof Error ? error.message : String(error ?? ''),
299
+ };
300
+ }
301
+ }
302
+ export async function runGeminiWebWithFallback(input) {
303
+ const attempt = await runGeminiWebOnce(input);
304
+ if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
305
+ const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
306
+ return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
307
+ }
308
+ return { ...attempt, effectiveModel: input.model };
309
+ }
310
+ export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath) {
311
+ const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
312
+ if (generatedOrWeb?.url) {
313
+ await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath);
314
+ return { saved: true, imageCount: output.images.length };
315
+ }
316
+ const ggdl = extractGgdlUrls(output.rawResponseText);
317
+ if (ggdl[0]) {
318
+ await downloadGeminiImage(ggdl[0], cookieMap, outputPath);
319
+ return { saved: true, imageCount: ggdl.length };
320
+ }
321
+ return { saved: false, imageCount: 0 };
322
+ }
@@ -0,0 +1,204 @@
1
+ import path from 'node:path';
2
+ import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
3
+ function estimateTokenCount(text) {
4
+ return Math.ceil(text.length / 4);
5
+ }
6
+ function resolveInvocationPath(value) {
7
+ if (!value)
8
+ return undefined;
9
+ const trimmed = value.trim();
10
+ if (!trimmed)
11
+ return undefined;
12
+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
13
+ }
14
+ function resolveGeminiWebModel(desiredModel, log) {
15
+ const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
16
+ if (!desired)
17
+ return 'gemini-3-pro';
18
+ switch (desired) {
19
+ case 'gemini-3-pro':
20
+ case 'gemini-3.0-pro':
21
+ return 'gemini-3-pro';
22
+ case 'gemini-2.5-pro':
23
+ return 'gemini-2.5-pro';
24
+ case 'gemini-2.5-flash':
25
+ return 'gemini-2.5-flash';
26
+ default:
27
+ if (desired.startsWith('gemini-')) {
28
+ log?.(`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`);
29
+ }
30
+ return 'gemini-3-pro';
31
+ }
32
+ }
33
+ async function loadGeminiCookiesFromChrome(browserConfig, log) {
34
+ try {
35
+ const mod = (await import('chrome-cookies-secure'));
36
+ const chromeCookies = mod.default ??
37
+ mod;
38
+ const profile = typeof browserConfig?.chromeProfile === 'string' &&
39
+ browserConfig.chromeProfile.trim().length > 0
40
+ ? browserConfig.chromeProfile.trim()
41
+ : undefined;
42
+ const sources = [
43
+ 'https://gemini.google.com',
44
+ 'https://accounts.google.com',
45
+ 'https://www.google.com',
46
+ ];
47
+ const wantNames = [
48
+ '__Secure-1PSID',
49
+ '__Secure-1PSIDTS',
50
+ '__Secure-1PSIDCC',
51
+ '__Secure-1PAPISID',
52
+ 'NID',
53
+ 'AEC',
54
+ 'SOCS',
55
+ '__Secure-BUCKET',
56
+ '__Secure-ENID',
57
+ 'SID',
58
+ 'HSID',
59
+ 'SSID',
60
+ 'APISID',
61
+ 'SAPISID',
62
+ '__Secure-3PSID',
63
+ '__Secure-3PSIDTS',
64
+ '__Secure-3PAPISID',
65
+ 'SIDCC',
66
+ ];
67
+ const cookieMap = {};
68
+ for (const url of sources) {
69
+ const cookies = (await chromeCookies.getCookiesPromised(url, 'puppeteer', profile));
70
+ for (const name of wantNames) {
71
+ if (cookieMap[name])
72
+ continue;
73
+ const matches = cookies.filter((cookie) => cookie.name === name);
74
+ if (matches.length === 0)
75
+ continue;
76
+ const preferredDomain = matches.find((cookie) => cookie.domain === '.google.com' && (cookie.path ?? '/') === '/');
77
+ const googleDomain = matches.find((cookie) => (cookie.domain ?? '').endsWith('google.com'));
78
+ const value = (preferredDomain ?? googleDomain ?? matches[0])?.value;
79
+ if (value)
80
+ cookieMap[name] = value;
81
+ }
82
+ }
83
+ if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
84
+ return {};
85
+ }
86
+ log?.(`[gemini-web] Loaded Gemini cookies from Chrome (node): ${Object.keys(cookieMap).length} cookie(s).`);
87
+ return cookieMap;
88
+ }
89
+ catch (error) {
90
+ log?.(`[gemini-web] Failed to load Chrome cookies via node: ${error instanceof Error ? error.message : String(error ?? '')}`);
91
+ return {};
92
+ }
93
+ }
94
+ export function createGeminiWebExecutor(geminiOptions) {
95
+ return async (runOptions) => {
96
+ const startTime = Date.now();
97
+ const log = runOptions.log;
98
+ log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
99
+ const cookieMap = await loadGeminiCookiesFromChrome(runOptions.config, log);
100
+ if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
101
+ throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
102
+ }
103
+ const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
104
+ const editImagePath = resolveInvocationPath(geminiOptions.editImage);
105
+ const outputPath = resolveInvocationPath(geminiOptions.outputPath);
106
+ const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
107
+ let prompt = runOptions.prompt;
108
+ if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
109
+ prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
110
+ }
111
+ if (geminiOptions.youtube) {
112
+ prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
113
+ }
114
+ if (generateImagePath && !editImagePath) {
115
+ prompt = `Generate an image: ${prompt}`;
116
+ }
117
+ const model = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
118
+ let response;
119
+ if (editImagePath) {
120
+ const intro = await runGeminiWebWithFallback({
121
+ prompt: 'Here is an image to edit',
122
+ files: [editImagePath],
123
+ model,
124
+ cookieMap,
125
+ chatMetadata: null,
126
+ });
127
+ const editPrompt = `Use image generation tool to ${prompt}`;
128
+ const out = await runGeminiWebWithFallback({
129
+ prompt: editPrompt,
130
+ files: attachmentPaths,
131
+ model,
132
+ cookieMap,
133
+ chatMetadata: intro.metadata,
134
+ });
135
+ response = {
136
+ text: out.text ?? null,
137
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
138
+ has_images: false,
139
+ image_count: 0,
140
+ };
141
+ const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
142
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath);
143
+ response.has_images = imageSave.saved;
144
+ response.image_count = imageSave.imageCount;
145
+ if (!imageSave.saved) {
146
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
147
+ }
148
+ }
149
+ else if (generateImagePath) {
150
+ const out = await runGeminiWebWithFallback({
151
+ prompt,
152
+ files: attachmentPaths,
153
+ model,
154
+ cookieMap,
155
+ chatMetadata: null,
156
+ });
157
+ response = {
158
+ text: out.text ?? null,
159
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
160
+ has_images: false,
161
+ image_count: 0,
162
+ };
163
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath);
164
+ response.has_images = imageSave.saved;
165
+ response.image_count = imageSave.imageCount;
166
+ if (!imageSave.saved) {
167
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
168
+ }
169
+ }
170
+ else {
171
+ const out = await runGeminiWebWithFallback({
172
+ prompt,
173
+ files: attachmentPaths,
174
+ model,
175
+ cookieMap,
176
+ chatMetadata: null,
177
+ });
178
+ response = {
179
+ text: out.text ?? null,
180
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
181
+ has_images: out.images.length > 0,
182
+ image_count: out.images.length,
183
+ };
184
+ }
185
+ const answerText = response.text ?? '';
186
+ let answerMarkdown = answerText;
187
+ if (geminiOptions.showThoughts && response.thoughts) {
188
+ answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
189
+ }
190
+ if (response.has_images && response.image_count > 0) {
191
+ const imagePath = generateImagePath || outputPath || 'generated.png';
192
+ answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
193
+ }
194
+ const tookMs = Date.now() - startTime;
195
+ log?.(`[gemini-web] Completed in ${tookMs}ms`);
196
+ return {
197
+ answerText,
198
+ answerMarkdown,
199
+ tookMs,
200
+ answerTokens: estimateTokenCount(answerText),
201
+ answerChars: answerText.length,
202
+ };
203
+ };
204
+ }
@@ -0,0 +1 @@
1
+ export { createGeminiWebExecutor } from './executor.js';
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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",
@@ -86,7 +86,7 @@
86
86
  "scripts": {
87
87
  "docs:list": "tsx scripts/docs-list.ts",
88
88
  "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
89
- "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const src=path.join('vendor','oracle-notifier'); const dest=path.join('dist','vendor','oracle-notifier'); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){ fs.cpSync(src,dest,{recursive:true,force:true}); }\"",
89
+ "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const vendorRoot=path.join('dist','vendor'); fs.rmSync(vendorRoot,{recursive:true,force:true}); const vendors=[['oracle-notifier']]; vendors.forEach(([name])=>{const src=path.join('vendor',name); const dest=path.join(vendorRoot,name); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){fs.cpSync(src,dest,{recursive:true,force:true});}});\"",
90
90
  "start": "pnpm run build && node ./dist/scripts/run-cli.js",
91
91
  "oracle": "pnpm start",
92
92
  "check": "pnpm run typecheck",
@@ -1,20 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
- <plist version="1.0">
4
- <dict>
5
- <key>CFBundleIdentifier</key>
6
- <string>com.steipete.oracle.notifier</string>
7
- <key>CFBundleName</key>
8
- <string>OracleNotifier</string>
9
- <key>CFBundleDisplayName</key>
10
- <string>Oracle Notifier</string>
11
- <key>CFBundleExecutable</key>
12
- <string>OracleNotifier</string>
13
- <key>CFBundleIconFile</key>
14
- <string>OracleIcon</string>
15
- <key>CFBundlePackageType</key>
16
- <string>APPL</string>
17
- <key>LSMinimumSystemVersion</key>
18
- <string>13.0</string>
19
- </dict>
20
- </plist>
@@ -1,128 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
- <plist version="1.0">
4
- <dict>
5
- <key>files</key>
6
- <dict>
7
- <key>Resources/OracleIcon.icns</key>
8
- <data>
9
- edUHAMetayIv3xtc3Vb92VXRLfM=
10
- </data>
11
- </dict>
12
- <key>files2</key>
13
- <dict>
14
- <key>Resources/OracleIcon.icns</key>
15
- <dict>
16
- <key>hash2</key>
17
- <data>
18
- AVPJK/6w6IOsDLmZTW4hL+Za+/4wHMxZiIp0t6m3NRA=
19
- </data>
20
- </dict>
21
- </dict>
22
- <key>rules</key>
23
- <dict>
24
- <key>^Resources/</key>
25
- <true/>
26
- <key>^Resources/.*\.lproj/</key>
27
- <dict>
28
- <key>optional</key>
29
- <true/>
30
- <key>weight</key>
31
- <real>1000</real>
32
- </dict>
33
- <key>^Resources/.*\.lproj/locversion.plist$</key>
34
- <dict>
35
- <key>omit</key>
36
- <true/>
37
- <key>weight</key>
38
- <real>1100</real>
39
- </dict>
40
- <key>^Resources/Base\.lproj/</key>
41
- <dict>
42
- <key>weight</key>
43
- <real>1010</real>
44
- </dict>
45
- <key>^version.plist$</key>
46
- <true/>
47
- </dict>
48
- <key>rules2</key>
49
- <dict>
50
- <key>.*\.dSYM($|/)</key>
51
- <dict>
52
- <key>weight</key>
53
- <real>11</real>
54
- </dict>
55
- <key>^(.*/)?\.DS_Store$</key>
56
- <dict>
57
- <key>omit</key>
58
- <true/>
59
- <key>weight</key>
60
- <real>2000</real>
61
- </dict>
62
- <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
63
- <dict>
64
- <key>nested</key>
65
- <true/>
66
- <key>weight</key>
67
- <real>10</real>
68
- </dict>
69
- <key>^.*</key>
70
- <true/>
71
- <key>^Info\.plist$</key>
72
- <dict>
73
- <key>omit</key>
74
- <true/>
75
- <key>weight</key>
76
- <real>20</real>
77
- </dict>
78
- <key>^PkgInfo$</key>
79
- <dict>
80
- <key>omit</key>
81
- <true/>
82
- <key>weight</key>
83
- <real>20</real>
84
- </dict>
85
- <key>^Resources/</key>
86
- <dict>
87
- <key>weight</key>
88
- <real>20</real>
89
- </dict>
90
- <key>^Resources/.*\.lproj/</key>
91
- <dict>
92
- <key>optional</key>
93
- <true/>
94
- <key>weight</key>
95
- <real>1000</real>
96
- </dict>
97
- <key>^Resources/.*\.lproj/locversion.plist$</key>
98
- <dict>
99
- <key>omit</key>
100
- <true/>
101
- <key>weight</key>
102
- <real>1100</real>
103
- </dict>
104
- <key>^Resources/Base\.lproj/</key>
105
- <dict>
106
- <key>weight</key>
107
- <real>1010</real>
108
- </dict>
109
- <key>^[^/]+$</key>
110
- <dict>
111
- <key>nested</key>
112
- <true/>
113
- <key>weight</key>
114
- <real>10</real>
115
- </dict>
116
- <key>^embedded\.provisionprofile$</key>
117
- <dict>
118
- <key>weight</key>
119
- <real>20</real>
120
- </dict>
121
- <key>^version\.plist$</key>
122
- <dict>
123
- <key>weight</key>
124
- <real>20</real>
125
- </dict>
126
- </dict>
127
- </dict>
128
- </plist>
@@ -1,45 +0,0 @@
1
- import Foundation
2
- import UserNotifications
3
-
4
- let args = CommandLine.arguments
5
- // Usage: OracleNotifier <title> <message> [soundName]
6
- if args.count < 3 {
7
- fputs("usage: OracleNotifier <title> <message> [soundName]\n", stderr)
8
- exit(1)
9
- }
10
- let title = args[1]
11
- let message = args[2]
12
- let soundName = args.count >= 4 ? args[3] : "Glass"
13
-
14
- let center = UNUserNotificationCenter.current()
15
- let group = DispatchGroup()
16
- group.enter()
17
- center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
18
- if let error = error {
19
- fputs("auth error: \(error)\n", stderr)
20
- group.leave()
21
- return
22
- }
23
- if !granted {
24
- fputs("authorization not granted\n", stderr)
25
- group.leave()
26
- return
27
- }
28
- let content = UNMutableNotificationContent()
29
- content.title = title
30
- content.body = message
31
- if !soundName.isEmpty {
32
- content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
33
- } else {
34
- content.sound = UNNotificationSound.default
35
- }
36
- let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
37
- center.add(request) { addError in
38
- if let addError = addError {
39
- fputs("add error: \(addError)\n", stderr)
40
- }
41
- group.leave()
42
- }
43
- }
44
- _ = group.wait(timeout: .now() + 2)
45
- RunLoop.current.run(until: Date().addingTimeInterval(1))
@@ -1,24 +0,0 @@
1
- # Oracle Notifier helper (macOS, arm64)
2
-
3
- Builds a tiny signed helper app for macOS notifications with the Oracle icon.
4
-
5
- ## Build
6
-
7
- ```bash
8
- cd vendor/oracle-notifier
9
- # Optional: notarize by setting App Store Connect key credentials
10
- export APP_STORE_CONNECT_API_KEY_P8="$(cat AuthKey_XXXXXX.p8)" # with literal newlines or \n escaped
11
- export APP_STORE_CONNECT_KEY_ID=XXXXXX
12
- export APP_STORE_CONNECT_ISSUER_ID=YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
13
- ./build-notifier.sh
14
- ```
15
-
16
- - Requires Xcode command line tools (swiftc) and a macOS Developer ID certificate. Without a valid cert, the build fails (no ad-hoc fallback).
17
- - If `APP_STORE_CONNECT_*` vars are set, the script notarizes and staples the ticket.
18
- - Output: `OracleNotifier.app` (arm64 only), bundled with `OracleIcon.icns`.
19
-
20
- ## Usage
21
- The CLI prefers this helper on macOS; if it fails or is missing, it falls back to toasted-notifier/terminal-notifier.
22
-
23
- ## Permissions
24
- After first run, allow notifications for “Oracle Notifier” in System Settings → Notifications.
@@ -1,93 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- cd "$(dirname "$0")"
4
-
5
- ICON_SRC=../../assets-oracle-icon.png
6
- APP=OracleNotifier.app
7
- CONTENTS="$APP/Contents"
8
- MACOS="$CONTENTS/MacOS"
9
- RESOURCES="$CONTENTS/Resources"
10
- ICONSET=OracleIcon.iconset
11
- ICNS=OracleIcon.icns
12
- IDENTITY="${CODESIGN_ID:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
13
- ZIP="/tmp/OracleNotifierNotarize.zip"
14
-
15
- NOTARY_KEY_P8="${APP_STORE_CONNECT_API_KEY_P8:-}"
16
- NOTARY_KEY_ID="${APP_STORE_CONNECT_KEY_ID:-}"
17
- NOTARY_ISSUER_ID="${APP_STORE_CONNECT_ISSUER_ID:-}"
18
- DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
19
-
20
- cleanup() {
21
- rm -f "$ZIP" /tmp/oracle-notifier-api-key.p8
22
- }
23
- trap cleanup EXIT
24
-
25
- rm -rf "$APP" "$ICONSET" "$ICNS"
26
- mkdir -p "$MACOS" "$RESOURCES"
27
-
28
- # Build ICNS from PNG
29
- mkdir "$ICONSET"
30
- for sz in 16 32 64 128 256 512; do
31
- sips -z $sz $sz "$ICON_SRC" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null
32
- sips -z $((sz*2)) $((sz*2)) "$ICON_SRC" --out "$ICONSET/icon_${sz}x${sz}@2x.png" >/dev/null
33
- done
34
- iconutil -c icns --output "$ICNS" "$ICONSET"
35
- mv "$ICNS" "$RESOURCES/OracleIcon.icns"
36
- rm -rf "$ICONSET"
37
-
38
- # Write Info.plist
39
- cat > "$CONTENTS/Info.plist" <<'PLIST'
40
- <?xml version="1.0" encoding="UTF-8"?>
41
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42
- <plist version="1.0">
43
- <dict>
44
- <key>CFBundleIdentifier</key>
45
- <string>com.steipete.oracle.notifier</string>
46
- <key>CFBundleName</key>
47
- <string>OracleNotifier</string>
48
- <key>CFBundleDisplayName</key>
49
- <string>Oracle Notifier</string>
50
- <key>CFBundleExecutable</key>
51
- <string>OracleNotifier</string>
52
- <key>CFBundleIconFile</key>
53
- <string>OracleIcon</string>
54
- <key>CFBundlePackageType</key>
55
- <string>APPL</string>
56
- <key>LSMinimumSystemVersion</key>
57
- <string>13.0</string>
58
- </dict>
59
- </plist>
60
- PLIST
61
-
62
- # Compile Swift helper (arm64)
63
- swiftc -target arm64-apple-macos13 -o "$MACOS/OracleNotifier" OracleNotifier.swift -framework Foundation -framework UserNotifications
64
-
65
- echo "Signing with $IDENTITY"
66
- if ! codesign --force --deep --options runtime --timestamp --sign "$IDENTITY" "$APP"; then
67
- echo "codesign failed. Set CODESIGN_ID to a valid Developer ID Application certificate." >&2
68
- exit 1
69
- fi
70
-
71
- # Notarize if credentials are provided
72
- if [[ -n "$NOTARY_KEY_P8" && -n "$NOTARY_KEY_ID" && -n "$NOTARY_ISSUER_ID" ]]; then
73
- echo "$NOTARY_KEY_P8" | sed 's/\\n/\n/g' > /tmp/oracle-notifier-api-key.p8
74
- echo "Packaging for notarization"
75
- "$DITTO_BIN" -c -k --keepParent --sequesterRsrc "$APP" "$ZIP"
76
-
77
- echo "Submitting for notarization"
78
- xcrun notarytool submit "$ZIP" \
79
- --key /tmp/oracle-notifier-api-key.p8 \
80
- --key-id "$NOTARY_KEY_ID" \
81
- --issuer "$NOTARY_ISSUER_ID" \
82
- --wait
83
-
84
- echo "Stapling ticket"
85
- xcrun stapler staple "$APP"
86
- xcrun stapler validate "$APP"
87
- else
88
- echo "Skipping notarization (APP_STORE_CONNECT_* env vars not set)."
89
- fi
90
-
91
- spctl -a -t exec -vv "$APP" || true
92
-
93
- echo "Built $APP"