@steipete/oracle 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +40 -7
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/.DS_Store +0 -0
  4. package/dist/bin/oracle-cli.js +315 -47
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/src/browser/actions/modelSelection.js +117 -29
  7. package/dist/src/browser/config.js +6 -0
  8. package/dist/src/browser/cookies.js +50 -12
  9. package/dist/src/browser/index.js +19 -5
  10. package/dist/src/browser/prompt.js +6 -5
  11. package/dist/src/browser/sessionRunner.js +14 -3
  12. package/dist/src/cli/browserConfig.js +109 -2
  13. package/dist/src/cli/detach.js +12 -0
  14. package/dist/src/cli/dryRun.js +60 -8
  15. package/dist/src/cli/engine.js +7 -0
  16. package/dist/src/cli/help.js +3 -1
  17. package/dist/src/cli/hiddenAliases.js +17 -0
  18. package/dist/src/cli/markdownRenderer.js +79 -0
  19. package/dist/src/cli/notifier.js +223 -0
  20. package/dist/src/cli/options.js +22 -0
  21. package/dist/src/cli/promptRequirement.js +3 -0
  22. package/dist/src/cli/runOptions.js +43 -0
  23. package/dist/src/cli/sessionCommand.js +1 -1
  24. package/dist/src/cli/sessionDisplay.js +94 -7
  25. package/dist/src/cli/sessionRunner.js +32 -2
  26. package/dist/src/cli/tui/index.js +457 -0
  27. package/dist/src/config.js +27 -0
  28. package/dist/src/mcp/server.js +36 -0
  29. package/dist/src/mcp/tools/consult.js +158 -0
  30. package/dist/src/mcp/tools/sessionResources.js +64 -0
  31. package/dist/src/mcp/tools/sessions.js +106 -0
  32. package/dist/src/mcp/types.js +17 -0
  33. package/dist/src/mcp/utils.js +24 -0
  34. package/dist/src/oracle/client.js +24 -6
  35. package/dist/src/oracle/config.js +10 -0
  36. package/dist/src/oracle/files.js +151 -8
  37. package/dist/src/oracle/format.js +2 -7
  38. package/dist/src/oracle/fsAdapter.js +4 -1
  39. package/dist/src/oracle/gemini.js +161 -0
  40. package/dist/src/oracle/logging.js +36 -0
  41. package/dist/src/oracle/oscProgress.js +7 -1
  42. package/dist/src/oracle/run.js +148 -64
  43. package/dist/src/oracle/tokenEstimate.js +34 -0
  44. package/dist/src/oracle.js +1 -0
  45. package/dist/src/sessionManager.js +50 -3
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  48. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  49. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  50. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  51. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  52. package/dist/vendor/oracle-notifier/README.md +24 -0
  53. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  54. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  55. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  56. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  57. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  58. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  59. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  60. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  61. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  62. package/package.json +22 -6
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  68. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  69. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -4,12 +4,13 @@ import fg from 'fast-glob';
4
4
  import { FileValidationError } from './errors.js';
5
5
  const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB
6
6
  const DEFAULT_FS = fs;
7
+ const DEFAULT_IGNORED_DIRS = ['node_modules', 'dist', 'coverage', '.git', '.turbo', '.next', 'build', 'tmp'];
7
8
  export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES } = {}) {
8
9
  if (!filePaths || filePaths.length === 0) {
9
10
  return [];
10
11
  }
11
12
  const partitioned = await partitionFileInputs(filePaths, cwd, fsModule);
12
- const useNativeFilesystem = fsModule === DEFAULT_FS;
13
+ const useNativeFilesystem = fsModule === DEFAULT_FS || isNativeFsModule(fsModule);
13
14
  let candidatePaths = [];
14
15
  if (useNativeFilesystem) {
15
16
  candidatePaths = await expandWithNativeGlob(partitioned, cwd);
@@ -20,7 +21,28 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
20
21
  }
21
22
  candidatePaths = await expandWithCustomFs(partitioned, fsModule);
22
23
  }
23
- if (candidatePaths.length === 0) {
24
+ const allowedLiteralDirs = partitioned.literalDirectories
25
+ .map((dir) => path.resolve(dir))
26
+ .filter((dir) => DEFAULT_IGNORED_DIRS.includes(path.basename(dir)));
27
+ const allowedLiteralFiles = partitioned.literalFiles.map((file) => path.resolve(file));
28
+ const resolvedLiteralDirs = new Set(allowedLiteralDirs);
29
+ const allowedPaths = new Set([...allowedLiteralDirs, ...allowedLiteralFiles]);
30
+ const ignoredWhitelist = await buildIgnoredWhitelist(candidatePaths, cwd, fsModule);
31
+ const ignoredLog = new Set();
32
+ const filteredCandidates = candidatePaths.filter((filePath) => {
33
+ const ignoredDir = findIgnoredAncestor(filePath, cwd, resolvedLiteralDirs, allowedPaths, ignoredWhitelist);
34
+ if (!ignoredDir) {
35
+ return true;
36
+ }
37
+ const displayFile = relativePath(filePath, cwd);
38
+ const key = `${ignoredDir}|${displayFile}`;
39
+ if (!ignoredLog.has(key)) {
40
+ console.log(`Skipping default-ignored path: ${displayFile} (matches ${ignoredDir})`);
41
+ ignoredLog.add(key);
42
+ }
43
+ return false;
44
+ });
45
+ if (filteredCandidates.length === 0) {
24
46
  throw new FileValidationError('No files matched the provided --file patterns.', {
25
47
  patterns: partitioned.globPatterns,
26
48
  excludes: partitioned.excludePatterns,
@@ -28,7 +50,7 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
28
50
  }
29
51
  const oversized = [];
30
52
  const accepted = [];
31
- for (const filePath of candidatePaths) {
53
+ for (const filePath of filteredCandidates) {
32
54
  let stats;
33
55
  try {
34
56
  stats = await fsModule.stat(filePath);
@@ -111,15 +133,130 @@ async function expandWithNativeGlob(partitioned, cwd) {
111
133
  if (patterns.length === 0) {
112
134
  return [];
113
135
  }
114
- const matches = await fg(patterns, {
136
+ const dotfileOptIn = patterns.some((pattern) => includesDotfileSegment(pattern));
137
+ const gitignoreSets = await loadGitignoreSets(cwd);
138
+ const matches = (await fg(patterns, {
115
139
  cwd,
116
- absolute: true,
140
+ absolute: false,
117
141
  dot: true,
118
142
  ignore: partitioned.excludePatterns,
119
143
  onlyFiles: true,
120
144
  followSymbolicLinks: false,
121
- });
122
- return Array.from(new Set(matches.map((match) => path.resolve(match))));
145
+ }));
146
+ const resolved = matches.map((match) => path.resolve(cwd, match));
147
+ const filtered = resolved.filter((filePath) => !isGitignored(filePath, gitignoreSets));
148
+ const finalFiles = dotfileOptIn ? filtered : filtered.filter((filePath) => !path.basename(filePath).startsWith('.'));
149
+ return Array.from(new Set(finalFiles));
150
+ }
151
+ async function loadGitignoreSets(cwd) {
152
+ const gitignorePaths = await fg('**/.gitignore', { cwd, dot: true, absolute: true, onlyFiles: true, followSymbolicLinks: false });
153
+ const sets = [];
154
+ for (const filePath of gitignorePaths) {
155
+ try {
156
+ const raw = await fs.readFile(filePath, 'utf8');
157
+ const patterns = raw
158
+ .split('\n')
159
+ .map((line) => line.trim())
160
+ .filter((line) => line.length > 0 && !line.startsWith('#'));
161
+ if (patterns.length > 0) {
162
+ sets.push({ dir: path.dirname(filePath), patterns });
163
+ }
164
+ }
165
+ catch {
166
+ // Ignore unreadable .gitignore files
167
+ }
168
+ }
169
+ // Ensure deterministic parent-before-child ordering
170
+ return sets.sort((a, b) => a.dir.localeCompare(b.dir));
171
+ }
172
+ function isGitignored(filePath, sets) {
173
+ for (const { dir, patterns } of sets) {
174
+ if (!filePath.startsWith(dir)) {
175
+ continue;
176
+ }
177
+ const relative = path.relative(dir, filePath) || path.basename(filePath);
178
+ if (matchesAny(relative, patterns)) {
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ async function buildIgnoredWhitelist(filePaths, cwd, fsModule) {
185
+ const whitelist = new Set();
186
+ for (const filePath of filePaths) {
187
+ const absolute = path.resolve(filePath);
188
+ const rel = path.relative(cwd, absolute);
189
+ const parts = rel.split(path.sep).filter(Boolean);
190
+ for (let i = 0; i < parts.length - 1; i += 1) {
191
+ const part = parts[i];
192
+ if (!DEFAULT_IGNORED_DIRS.includes(part)) {
193
+ continue;
194
+ }
195
+ const dirPath = path.resolve(cwd, ...parts.slice(0, i + 1));
196
+ if (whitelist.has(dirPath)) {
197
+ continue;
198
+ }
199
+ try {
200
+ const stats = await fsModule.stat(path.join(dirPath, '.gitignore'));
201
+ if (stats.isFile()) {
202
+ whitelist.add(dirPath);
203
+ }
204
+ }
205
+ catch {
206
+ // no .gitignore at this level; keep ignored
207
+ }
208
+ }
209
+ }
210
+ return whitelist;
211
+ }
212
+ function findIgnoredAncestor(filePath, cwd, _literalDirs, allowedPaths, ignoredWhitelist) {
213
+ const absolute = path.resolve(filePath);
214
+ if (Array.from(allowedPaths).some((allowed) => absolute === allowed || absolute.startsWith(`${allowed}${path.sep}`))) {
215
+ return null; // explicitly requested path overrides default ignore when the ignored dir itself was passed
216
+ }
217
+ const rel = path.relative(cwd, absolute);
218
+ const parts = rel.split(path.sep);
219
+ for (let idx = 0; idx < parts.length; idx += 1) {
220
+ const part = parts[idx];
221
+ if (!DEFAULT_IGNORED_DIRS.includes(part)) {
222
+ continue;
223
+ }
224
+ const ignoredDir = path.resolve(cwd, parts.slice(0, idx + 1).join(path.sep));
225
+ if (ignoredWhitelist.has(ignoredDir)) {
226
+ continue;
227
+ }
228
+ return part;
229
+ }
230
+ return null;
231
+ }
232
+ function matchesAny(relativePath, patterns) {
233
+ return patterns.some((pattern) => matchesPattern(relativePath, pattern));
234
+ }
235
+ function matchesPattern(relativePath, pattern) {
236
+ if (!pattern) {
237
+ return false;
238
+ }
239
+ const normalized = pattern.replace(/\\+/g, '/');
240
+ // Directory rule
241
+ if (normalized.endsWith('/')) {
242
+ const dir = normalized.slice(0, -1);
243
+ return relativePath === dir || relativePath.startsWith(`${dir}/`);
244
+ }
245
+ // Simple glob support (* and **)
246
+ const regex = globToRegex(normalized);
247
+ return regex.test(relativePath);
248
+ }
249
+ function globToRegex(pattern) {
250
+ const withMarkers = pattern.replace(/\*\*/g, '§§DOUBLESTAR§§').replace(/\*/g, '§§SINGLESTAR§§');
251
+ const escaped = withMarkers.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
252
+ const restored = escaped
253
+ .replace(/§§DOUBLESTAR§§/g, '.*')
254
+ .replace(/§§SINGLESTAR§§/g, '[^/]*');
255
+ return new RegExp(`^${restored}$`);
256
+ }
257
+ function includesDotfileSegment(pattern) {
258
+ const segments = pattern.split('/');
259
+ return segments.some((segment) => segment.startsWith('.') && segment.length > 1);
123
260
  }
124
261
  async function expandWithCustomFs(partitioned, fsModule) {
125
262
  const paths = new Set();
@@ -155,6 +292,12 @@ function makeDirectoryPattern(relative) {
155
292
  }
156
293
  return `${stripTrailingSlashes(relative)}/**/*`;
157
294
  }
295
+ function isNativeFsModule(fsModule) {
296
+ return ((fsModule.__nativeFs === true ||
297
+ (fsModule.readFile === DEFAULT_FS.readFile &&
298
+ fsModule.stat === DEFAULT_FS.stat &&
299
+ fsModule.readdir === DEFAULT_FS.readdir)));
300
+ }
158
301
  function normalizeGlob(pattern, cwd) {
159
302
  if (!pattern) {
160
303
  return '';
@@ -202,7 +345,7 @@ function relativePath(targetPath, cwd) {
202
345
  }
203
346
  export function createFileSections(files, cwd = process.cwd()) {
204
347
  return files.map((file, index) => {
205
- const relative = path.relative(cwd, file.path) || file.path;
348
+ const relative = toPosix(path.relative(cwd, file.path) || file.path);
206
349
  const sectionText = [
207
350
  `### File ${index + 1}: ${relative}`,
208
351
  '```',
@@ -2,13 +2,8 @@ export function formatUSD(value) {
2
2
  if (!Number.isFinite(value)) {
3
3
  return 'n/a';
4
4
  }
5
- if (value >= 0.1) {
6
- return `$${value.toFixed(2)}`;
7
- }
8
- if (value >= 0.01) {
9
- return `$${value.toFixed(3)}`;
10
- }
11
- return `$${value.toFixed(6)}`;
5
+ // Display with 4 decimal places, rounding to $0.0001 minimum granularity.
6
+ return `$${value.toFixed(4)}`;
12
7
  }
13
8
  export function formatNumber(value, { estimated = false } = {}) {
14
9
  if (value == null) {
@@ -1,7 +1,10 @@
1
1
  export function createFsAdapter(fsModule) {
2
- return {
2
+ const adapter = {
3
3
  stat: (targetPath) => fsModule.stat(targetPath),
4
4
  readdir: (targetPath) => fsModule.readdir(targetPath),
5
5
  readFile: (targetPath, encoding) => fsModule.readFile(targetPath, encoding),
6
6
  };
7
+ // Mark adapters so downstream callers can treat them as native filesystem access.
8
+ adapter.__nativeFs = true;
9
+ return adapter;
7
10
  }
@@ -0,0 +1,161 @@
1
+ import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold, } from '@google/generative-ai';
2
+ const MODEL_ID_MAP = {
3
+ 'gemini-3-pro': 'gemini-3-pro-preview',
4
+ 'gpt-5-pro': 'gpt-5-pro', // unused, normalize TS map
5
+ 'gpt-5.1': 'gpt-5.1',
6
+ };
7
+ export function resolveGeminiModelId(modelName) {
8
+ // Map our logical Gemini names to the exact model ids expected by the SDK.
9
+ return MODEL_ID_MAP[modelName] ?? modelName;
10
+ }
11
+ export function createGeminiClient(apiKey, modelName = 'gemini-3-pro', resolvedModelId) {
12
+ const genAI = new GoogleGenerativeAI(apiKey);
13
+ const modelId = resolvedModelId ?? resolveGeminiModelId(modelName);
14
+ const model = genAI.getGenerativeModel({ model: modelId });
15
+ const adaptBodyToGemini = (body) => {
16
+ const contents = body.input.map((inputItem) => ({
17
+ role: inputItem.role === 'user' ? 'user' : 'model',
18
+ parts: inputItem.content
19
+ .map((contentPart) => {
20
+ if (contentPart.type === 'input_text') {
21
+ return { text: contentPart.text };
22
+ }
23
+ return null;
24
+ })
25
+ .filter((part) => part !== null),
26
+ }));
27
+ const tools = body.tools
28
+ ?.map((tool) => {
29
+ if (tool.type === 'web_search_preview') {
30
+ return {
31
+ googleSearch: {},
32
+ };
33
+ }
34
+ return {};
35
+ })
36
+ .filter((t) => Object.keys(t).length > 0);
37
+ const generationConfig = {
38
+ maxOutputTokens: body.max_output_tokens,
39
+ };
40
+ const safetySettings = [
41
+ {
42
+ category: HarmCategory.HARM_CATEGORY_HARASSMENT,
43
+ threshold: HarmBlockThreshold.BLOCK_NONE,
44
+ },
45
+ {
46
+ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
47
+ threshold: HarmBlockThreshold.BLOCK_NONE,
48
+ },
49
+ {
50
+ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
51
+ threshold: HarmBlockThreshold.BLOCK_NONE,
52
+ },
53
+ {
54
+ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
55
+ threshold: HarmBlockThreshold.BLOCK_NONE,
56
+ },
57
+ ];
58
+ const systemInstruction = body.instructions || undefined;
59
+ return { systemInstruction, contents, tools, generationConfig, safetySettings };
60
+ };
61
+ const adaptGeminiResponseToOracle = (geminiResponse) => {
62
+ const outputText = [];
63
+ const output = [];
64
+ geminiResponse.candidates?.forEach((candidate) => {
65
+ candidate.content?.parts?.forEach((part) => {
66
+ if (part.text) {
67
+ outputText.push(part.text);
68
+ output.push({ type: 'text', text: part.text });
69
+ }
70
+ });
71
+ });
72
+ const usage = {
73
+ input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
74
+ output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
75
+ total_tokens: (geminiResponse.usageMetadata?.promptTokenCount || 0) + (geminiResponse.usageMetadata?.candidatesTokenCount || 0),
76
+ };
77
+ return {
78
+ id: `gemini-${Date.now()}`, // Gemini doesn't always provide a stable ID in the response object
79
+ status: 'completed',
80
+ output_text: outputText,
81
+ output,
82
+ usage,
83
+ };
84
+ };
85
+ const enrichGeminiError = (error) => {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ if (message.includes('404')) {
88
+ return new Error(`Gemini model not available to this API key/region. Confirm preview access and model ID (${modelId}). Original: ${message}`);
89
+ }
90
+ return error instanceof Error ? error : new Error(message);
91
+ };
92
+ return {
93
+ responses: {
94
+ stream: (body) => {
95
+ const geminiBody = adaptBodyToGemini(body);
96
+ let finalResponsePromise = null;
97
+ const collectChunkText = (chunk) => {
98
+ const parts = [];
99
+ chunk.candidates?.forEach((candidate) => {
100
+ candidate.content?.parts?.forEach((part) => {
101
+ if (part.text) {
102
+ parts.push(part.text);
103
+ }
104
+ });
105
+ });
106
+ return parts.join('');
107
+ };
108
+ async function* iterator() {
109
+ let streamingResp;
110
+ try {
111
+ streamingResp = await model.generateContentStream(geminiBody);
112
+ }
113
+ catch (error) {
114
+ throw enrichGeminiError(error);
115
+ }
116
+ for await (const chunk of streamingResp.stream) {
117
+ const text = collectChunkText(chunk);
118
+ if (text) {
119
+ yield { type: 'chunk', delta: text };
120
+ }
121
+ }
122
+ finalResponsePromise = streamingResp.response.then(adaptGeminiResponseToOracle);
123
+ }
124
+ const generator = iterator();
125
+ return {
126
+ [Symbol.asyncIterator]: () => generator,
127
+ finalResponse: async () => {
128
+ // Ensure the stream has been consumed or at least started to get the promise
129
+ if (!finalResponsePromise) {
130
+ // In case the user calls finalResponse before iterating, we need to consume the stream
131
+ // This is a bit edge-casey but safe.
132
+ for await (const _ of generator) { }
133
+ }
134
+ if (!finalResponsePromise) {
135
+ throw new Error('Response promise not initialized');
136
+ }
137
+ return finalResponsePromise;
138
+ }
139
+ };
140
+ },
141
+ create: async (body) => {
142
+ const geminiBody = adaptBodyToGemini(body);
143
+ let result;
144
+ try {
145
+ result = await model.generateContent(geminiBody);
146
+ }
147
+ catch (error) {
148
+ throw enrichGeminiError(error);
149
+ }
150
+ return adaptGeminiResponseToOracle(result.response);
151
+ },
152
+ retrieve: async (id) => {
153
+ return {
154
+ id,
155
+ status: 'error',
156
+ error: { message: 'Retrieve by ID not supported for Gemini API yet.' },
157
+ };
158
+ },
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,36 @@
1
+ export function maskApiKey(key) {
2
+ if (!key)
3
+ return null;
4
+ if (key.length <= 8)
5
+ return `${key[0] ?? ''}***${key[key.length - 1] ?? ''}`;
6
+ const prefix = key.slice(0, 4);
7
+ const suffix = key.slice(-4);
8
+ return `${prefix}****${suffix}`;
9
+ }
10
+ export function formatBaseUrlForLog(raw) {
11
+ if (!raw)
12
+ return '';
13
+ try {
14
+ const parsed = new URL(raw);
15
+ const segments = parsed.pathname.split('/').filter(Boolean);
16
+ let path = '';
17
+ if (segments.length > 0) {
18
+ path = `/${segments[0]}`;
19
+ if (segments.length > 1) {
20
+ path += '/...';
21
+ }
22
+ }
23
+ const allowedQueryKeys = ['api-version'];
24
+ const maskedQuery = allowedQueryKeys
25
+ .filter((key) => parsed.searchParams.has(key))
26
+ .map((key) => `${key}=***`);
27
+ const query = maskedQuery.length > 0 ? `?${maskedQuery.join('&')}` : '';
28
+ return `${parsed.protocol}//${parsed.host}${path}${query}`;
29
+ }
30
+ catch {
31
+ const trimmed = raw.trim();
32
+ if (trimmed.length <= 64)
33
+ return trimmed;
34
+ return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`;
35
+ }
36
+ }
@@ -29,11 +29,17 @@ export function supportsOscProgress(env = process.env, isTty = process.stdout.is
29
29
  return false;
30
30
  }
31
31
  export function startOscProgress(options = {}) {
32
- const { label = 'Waiting for OpenAI', targetMs = 10 * 60_000, write = (text) => process.stdout.write(text) } = options;
32
+ const { label = 'Waiting for API', targetMs = 10 * 60_000, write = (text) => process.stdout.write(text), indeterminate = false, } = options;
33
33
  if (!supportsOscProgress(options.env, options.isTty)) {
34
34
  return () => { };
35
35
  }
36
36
  const cleanLabel = sanitizeLabel(label);
37
+ if (indeterminate) {
38
+ write(`${OSC}3;;${cleanLabel}${ST}`);
39
+ return () => {
40
+ write(`${OSC}0;0;${cleanLabel}${ST}`);
41
+ };
42
+ }
37
43
  const target = Math.max(targetMs, 1_000);
38
44
  const send = (state, percent) => {
39
45
  const clamped = Math.max(0, Math.min(100, Math.round(percent)));