@steipete/oracle 0.12.1 → 0.13.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.
@@ -7,14 +7,17 @@ import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
7
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
8
8
  import { isAzureOpenAICandidateModel } from "../oracle/providerRouting.js";
9
9
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, models, engine, userConfig, env = process.env, }) {
10
- const resolvedEngine = resolveEngineWithConfig({
10
+ const resolvedEngine = resolveEngine({
11
11
  engine,
12
12
  configEngine: userConfig?.engine,
13
13
  env,
14
14
  });
15
+ const envEnginePreference = (env.ORACLE_ENGINE ?? "").trim().toLowerCase();
15
16
  const browserRequested = engine === "browser";
16
- const browserConfigured = userConfig?.engine === "browser";
17
- const envBrowserConfigured = (env.ORACLE_ENGINE ?? "").trim().toLowerCase() === "browser";
17
+ const explicitApiEngineRequested = engine === "api" || (!engine && envEnginePreference === "api");
18
+ const browserConfigured = userConfig?.engine === "browser" && !explicitApiEngineRequested;
19
+ const envBrowserConfigured = !engine && envEnginePreference === "browser";
20
+ const browserEngineRequested = browserRequested || browserConfigured || envBrowserConfigured;
18
21
  const requestedModelList = Array.isArray(models) ? models : [];
19
22
  const normalizedRequestedModels = requestedModelList
20
23
  .map((entry) => normalizeModelOption(entry))
@@ -32,20 +35,17 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
32
35
  : [apiModel];
33
36
  const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel];
34
37
  const includesGeminiApiOnly = allModels.some((m) => m === "gemini-3.1-pro");
35
- if ((browserRequested || browserConfigured) && includesGeminiApiOnly) {
38
+ if (browserEngineRequested && includesGeminiApiOnly) {
36
39
  throw new PromptValidationError("gemini-3.1-pro is API-only today. Use --engine api or switch to gemini-3-pro for Gemini web.", { engine: "browser", models: allModels });
37
40
  }
38
41
  const isBrowserCompatible = (m) => m.startsWith("gpt-") || m.startsWith("gemini");
39
- const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) &&
40
- browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
42
+ const hasNonBrowserCompatibleTarget = browserEngineRequested && browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
41
43
  if (hasNonBrowserCompatibleTarget) {
42
44
  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 });
43
45
  }
44
46
  const azure = resolveAzureOptions(userConfig, env);
45
47
  const azureAutoApi = Boolean(azure?.endpoint) &&
46
- !browserRequested &&
47
- !browserConfigured &&
48
- !envBrowserConfigured &&
48
+ !browserEngineRequested &&
49
49
  allModels.some(isAzureOpenAICandidateModel);
50
50
  const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || isGeminiApiOnly || azureAutoApi);
51
51
  const fixedEngine = isCodex ||
@@ -89,17 +89,6 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
89
89
  };
90
90
  return { runOptions, resolvedEngine: fixedEngine, engineCoercedToApi };
91
91
  }
92
- function resolveEngineWithConfig({ engine, configEngine, apiProviderRequested, env, }) {
93
- if (engine)
94
- return engine;
95
- const envOverride = (env.ORACLE_ENGINE ?? "").trim().toLowerCase();
96
- if (envOverride === "api" || envOverride === "browser") {
97
- return envOverride;
98
- }
99
- if (configEngine)
100
- return configEngine;
101
- return resolveEngine({ engine: undefined, apiProviderRequested, env });
102
- }
103
92
  function resolveAzureOptions(userConfig, env) {
104
93
  const endpoint = env.AZURE_OPENAI_ENDPOINT ?? userConfig?.azure?.endpoint;
105
94
  if (!endpoint?.trim()) {
@@ -1,26 +1,181 @@
1
1
  import fs from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import JSON5 from "json5";
4
5
  import { getOracleHomeDir } from "./oracleHome.js";
5
- function resolveConfigPath() {
6
+ export const PROJECT_CONFIG_RELATIVE_PATH = path.join(".oracle", "config.json");
7
+ function resolveUserConfigPath() {
6
8
  return path.join(getOracleHomeDir(), "config.json");
7
9
  }
8
- export async function loadUserConfig() {
9
- const CONFIG_PATH = resolveConfigPath();
10
+ export async function loadUserConfig(options = {}) {
11
+ const userConfigPath = resolveUserConfigPath();
12
+ const userConfig = await readConfigFile(userConfigPath);
13
+ const projectConfigPaths = options.includeProject === false
14
+ ? []
15
+ : await discoverProjectConfigPaths({
16
+ cwd: options.cwd ?? process.cwd(),
17
+ userConfigPath,
18
+ });
19
+ const loadedConfigs = [];
20
+ if (userConfig.loaded) {
21
+ loadedConfigs.push(userConfig);
22
+ }
23
+ let merged = userConfig.loaded ? userConfig.config : {};
24
+ for (const projectConfigPath of projectConfigPaths) {
25
+ const projectConfig = await readConfigFile(projectConfigPath);
26
+ if (!projectConfig.loaded)
27
+ continue;
28
+ loadedConfigs.push(projectConfig);
29
+ merged = mergeUserConfig(merged, sanitizeProjectConfig(projectConfig.config));
30
+ }
31
+ const loadedPaths = loadedConfigs.map((entry) => entry.path);
32
+ return {
33
+ config: merged,
34
+ path: userConfigPath,
35
+ paths: loadedPaths,
36
+ loaded: userConfig.loaded,
37
+ };
38
+ }
39
+ async function readConfigFile(configPath) {
10
40
  try {
11
- const raw = await fs.readFile(CONFIG_PATH, "utf8");
41
+ const raw = await fs.readFile(configPath, "utf8");
12
42
  const parsed = JSON5.parse(raw);
13
- return { config: parsed ?? {}, path: CONFIG_PATH, loaded: true };
43
+ return { config: parsed ?? {}, path: configPath, loaded: true };
14
44
  }
15
45
  catch (error) {
16
46
  const code = error.code;
17
47
  if (code === "ENOENT") {
18
- return { config: {}, path: CONFIG_PATH, loaded: false };
48
+ return { config: {}, path: configPath, loaded: false };
19
49
  }
20
- console.warn(`Failed to read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
21
- return { config: {}, path: CONFIG_PATH, loaded: false };
50
+ console.warn(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
51
+ return { config: {}, path: configPath, loaded: false };
22
52
  }
23
53
  }
24
54
  export function configPath() {
25
- return resolveConfigPath();
55
+ return resolveUserConfigPath();
56
+ }
57
+ async function discoverProjectConfigPaths({ cwd, userConfigPath, }) {
58
+ const start = path.resolve(cwd);
59
+ const home = os.homedir();
60
+ const candidates = [];
61
+ const seen = new Set([path.resolve(userConfigPath)]);
62
+ let current = start;
63
+ while (true) {
64
+ if (current === home) {
65
+ break;
66
+ }
67
+ const candidate = path.join(current, PROJECT_CONFIG_RELATIVE_PATH);
68
+ const resolved = path.resolve(candidate);
69
+ if (!seen.has(resolved)) {
70
+ try {
71
+ const stat = await fs.stat(resolved);
72
+ if (stat.isFile()) {
73
+ candidates.unshift(resolved);
74
+ seen.add(resolved);
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ENOENT") {
79
+ console.warn(`Failed to inspect ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
82
+ }
83
+ const parent = path.dirname(current);
84
+ if (parent === current) {
85
+ break;
86
+ }
87
+ current = parent;
88
+ }
89
+ return candidates;
90
+ }
91
+ function mergeUserConfig(base, override) {
92
+ return deepMerge(base, override);
93
+ }
94
+ function isRecord(value) {
95
+ return typeof value === "object" && value !== null && !Array.isArray(value);
96
+ }
97
+ function deepMerge(base, override) {
98
+ if (!isRecord(base) || !isRecord(override)) {
99
+ return override;
100
+ }
101
+ const result = { ...base };
102
+ for (const [key, value] of Object.entries(override)) {
103
+ const existing = result[key];
104
+ result[key] = isRecord(existing) && isRecord(value) ? deepMerge(existing, value) : value;
105
+ }
106
+ return result;
107
+ }
108
+ function sanitizeProjectConfig(config) {
109
+ const sanitized = {};
110
+ if (config.engine !== undefined)
111
+ sanitized.engine = config.engine;
112
+ if (config.model !== undefined)
113
+ sanitized.model = config.model;
114
+ if (config.search !== undefined)
115
+ sanitized.search = config.search;
116
+ if (config.maxFileSizeBytes !== undefined)
117
+ sanitized.maxFileSizeBytes = config.maxFileSizeBytes;
118
+ if (config.notify !== undefined)
119
+ sanitized.notify = config.notify;
120
+ if (config.heartbeatSeconds !== undefined)
121
+ sanitized.heartbeatSeconds = config.heartbeatSeconds;
122
+ if (config.filesReport !== undefined)
123
+ sanitized.filesReport = config.filesReport;
124
+ if (config.background !== undefined)
125
+ sanitized.background = config.background;
126
+ if (config.promptSuffix !== undefined)
127
+ sanitized.promptSuffix = config.promptSuffix;
128
+ if (config.browser) {
129
+ sanitized.browser = {};
130
+ const browser = config.browser;
131
+ const allowedBrowserKeys = [
132
+ "attachRunning",
133
+ "timeoutMs",
134
+ "inputTimeoutMs",
135
+ "attachmentTimeoutMs",
136
+ "assistantRecheckDelayMs",
137
+ "assistantRecheckTimeoutMs",
138
+ "reuseChromeWaitMs",
139
+ "profileLockTimeoutMs",
140
+ "maxConcurrentTabs",
141
+ "autoReattachDelayMs",
142
+ "autoReattachIntervalMs",
143
+ "autoReattachTimeoutMs",
144
+ "cookieSyncWaitMs",
145
+ "hideWindow",
146
+ "keepBrowser",
147
+ "modelStrategy",
148
+ "thinkingTime",
149
+ "researchMode",
150
+ "archiveConversations",
151
+ "manualLogin",
152
+ ];
153
+ for (const key of allowedBrowserKeys) {
154
+ if (browser[key] !== undefined) {
155
+ sanitized.browser[key] = browser[key];
156
+ }
157
+ }
158
+ const chatgptUrl = browser.chatgptUrl ?? browser.url;
159
+ if (chatgptUrl === null ||
160
+ (chatgptUrl !== undefined && isTrustedProjectChatgptUrl(chatgptUrl))) {
161
+ sanitized.browser.chatgptUrl = chatgptUrl;
162
+ sanitized.browser.url = chatgptUrl;
163
+ }
164
+ }
165
+ return sanitized;
166
+ }
167
+ function isTrustedProjectChatgptUrl(rawUrl) {
168
+ if (!rawUrl) {
169
+ return false;
170
+ }
171
+ try {
172
+ const parsed = new URL(rawUrl);
173
+ if (parsed.protocol !== "https:") {
174
+ return false;
175
+ }
176
+ return parsed.hostname === "chatgpt.com" || parsed.hostname === "chat.openai.com";
177
+ }
178
+ catch {
179
+ return false;
180
+ }
26
181
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
- "homepage": "https://github.com/steipete/oracle#readme",
6
+ "homepage": "https://askoracle.sh",
7
7
  "bugs": {
8
8
  "url": "https://github.com/steipete/oracle/issues"
9
9
  },