@jojonax/codex-copilot 1.5.5 → 1.6.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/LICENSE +21 -21
- package/README.md +144 -44
- package/bin/cli.js +189 -182
- package/package.json +39 -39
- package/src/commands/evolve.js +316 -316
- package/src/commands/fix.js +447 -447
- package/src/commands/init.js +298 -298
- package/src/commands/reset.js +61 -61
- package/src/commands/retry.js +190 -190
- package/src/commands/run.js +958 -958
- package/src/commands/skip.js +62 -62
- package/src/commands/status.js +95 -95
- package/src/commands/usage.js +361 -361
- package/src/utils/automator.js +279 -279
- package/src/utils/checkpoint.js +246 -246
- package/src/utils/detect-prd.js +137 -137
- package/src/utils/git.js +388 -388
- package/src/utils/github.js +486 -486
- package/src/utils/json.js +220 -220
- package/src/utils/logger.js +41 -41
- package/src/utils/prompt.js +49 -49
- package/src/utils/provider.js +770 -769
- package/src/utils/self-heal.js +330 -330
- package/src/utils/shell-bootstrap.js +404 -0
- package/src/utils/update-check.js +103 -103
package/src/utils/provider.js
CHANGED
|
@@ -1,769 +1,770 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AI Provider Registry — supports multiple AI coding tools
|
|
3
|
-
*
|
|
4
|
-
* Each provider has its own invocation pattern:
|
|
5
|
-
* - Codex CLI: piped stdin → codex exec --full-auto
|
|
6
|
-
* - Claude Code: -p flag + --allowedTools for safe auto-execution
|
|
7
|
-
* - Cursor Agent: cursor-agent CLI with -p headless mode
|
|
8
|
-
* - Gemini CLI: gemini -p for non-interactive prompt
|
|
9
|
-
* - Codex Desktop / Cursor IDE / Antigravity IDE: clipboard + manual
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { execSync, spawn } from 'child_process';
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
14
|
-
import { resolve } from 'path';
|
|
15
|
-
import { log } from './logger.js';
|
|
16
|
-
import { ask } from './prompt.js';
|
|
17
|
-
import { automator } from './automator.js';
|
|
18
|
-
|
|
19
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
20
|
-
|
|
21
|
-
// ──────────────────────────────────────────────
|
|
22
|
-
// Provider Registry — each provider is unique
|
|
23
|
-
// ──────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
const PROVIDERS = {
|
|
26
|
-
// ─── CLI Providers (auto-execute) ───
|
|
27
|
-
|
|
28
|
-
'codex-cli': {
|
|
29
|
-
name: 'Codex CLI',
|
|
30
|
-
type: 'cli',
|
|
31
|
-
detect: 'codex',
|
|
32
|
-
// Codex uses piped stdin: cat file | codex exec --full-auto -
|
|
33
|
-
buildCommand: (promptPath, cwd) =>
|
|
34
|
-
`cat ${shellEscape(promptPath)} | codex exec --full-auto -`,
|
|
35
|
-
description: 'OpenAI Codex CLI — pipes prompt via stdin',
|
|
36
|
-
version: {
|
|
37
|
-
command: 'codex --version',
|
|
38
|
-
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
39
|
-
latest: { type: 'brew-cask', name: 'codex' },
|
|
40
|
-
update: 'brew upgrade --cask codex',
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
'claude-code': {
|
|
45
|
-
name: 'Claude Code',
|
|
46
|
-
type: 'cli',
|
|
47
|
-
detect: 'claude',
|
|
48
|
-
// Claude Code uses -p (print/non-interactive) + --allowedTools for permissions
|
|
49
|
-
// Reads the file content and passes as argument (not piped)
|
|
50
|
-
buildCommand: (promptPath, cwd) => {
|
|
51
|
-
const escaped = shellEscape(promptPath);
|
|
52
|
-
// Use subshell to read file content into -p argument
|
|
53
|
-
return `claude -p "$(cat ${escaped})" --allowedTools "Bash(git*),Read,Write,Edit"`;
|
|
54
|
-
},
|
|
55
|
-
description: 'Anthropic Claude Code CLI — -p mode with tool permissions',
|
|
56
|
-
version: {
|
|
57
|
-
command: 'claude --version',
|
|
58
|
-
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
59
|
-
latest: { type: 'npm', name: '@anthropic-ai/claude-code' },
|
|
60
|
-
update: 'npm update -g @anthropic-ai/claude-code',
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
'cursor-agent': {
|
|
65
|
-
name: 'Cursor Agent',
|
|
66
|
-
type: 'cli',
|
|
67
|
-
detect: 'cursor-agent',
|
|
68
|
-
// Cursor Agent uses -p flag for headless/non-interactive mode
|
|
69
|
-
buildCommand: (promptPath, cwd) => {
|
|
70
|
-
const escaped = shellEscape(promptPath);
|
|
71
|
-
return `cursor-agent -p "$(cat ${escaped})"`;
|
|
72
|
-
},
|
|
73
|
-
description: 'Cursor Agent CLI — headless -p mode',
|
|
74
|
-
// No standard package manager — skip version check
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
'gemini-cli': {
|
|
78
|
-
name: 'Gemini CLI',
|
|
79
|
-
type: 'cli',
|
|
80
|
-
detect: 'gemini',
|
|
81
|
-
// Gemini CLI uses -p for non-interactive prompt execution
|
|
82
|
-
buildCommand: (promptPath, cwd) => {
|
|
83
|
-
const escaped = shellEscape(promptPath);
|
|
84
|
-
return `gemini -p "$(cat ${escaped})"`;
|
|
85
|
-
},
|
|
86
|
-
description: 'Google Gemini CLI — non-interactive -p mode',
|
|
87
|
-
version: {
|
|
88
|
-
command: 'gemini --version',
|
|
89
|
-
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
90
|
-
latest: { type: 'brew', name: 'gemini-cli' },
|
|
91
|
-
update: 'brew upgrade gemini-cli',
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
|
|
95
|
-
// ─── IDE Providers (clipboard + manual) ───
|
|
96
|
-
|
|
97
|
-
'codex-desktop': {
|
|
98
|
-
name: 'Codex Desktop',
|
|
99
|
-
type: 'ide',
|
|
100
|
-
instructions: 'Open Codex Desktop → paste the prompt → execute',
|
|
101
|
-
displayPrompt: true, // Show the prompt content in terminal box
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
'cursor': {
|
|
105
|
-
name: 'Cursor IDE',
|
|
106
|
-
type: 'ide',
|
|
107
|
-
instructions: 'Open Cursor → Ctrl/Cmd+I (Composer) → paste the prompt → run as Agent',
|
|
108
|
-
displayPrompt: false, // Too long to display, just copy to clipboard
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
'antigravity': {
|
|
112
|
-
name: 'Antigravity (Gemini)',
|
|
113
|
-
type: 'ide',
|
|
114
|
-
instructions: 'Open Antigravity → paste the prompt into chat → execute',
|
|
115
|
-
displayPrompt: false,
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Get a provider by ID
|
|
121
|
-
*/
|
|
122
|
-
export function getProvider(id) {
|
|
123
|
-
return PROVIDERS[id] || null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Get all provider IDs
|
|
128
|
-
*/
|
|
129
|
-
export function getAllProviderIds() {
|
|
130
|
-
return Object.keys(PROVIDERS);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Detect which CLI providers are installed on the system
|
|
135
|
-
* @returns {string[]} list of available provider IDs
|
|
136
|
-
*/
|
|
137
|
-
export function detectAvailable() {
|
|
138
|
-
const available = [];
|
|
139
|
-
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
140
|
-
if (prov.type === 'cli' && prov.detect) {
|
|
141
|
-
try {
|
|
142
|
-
const cmd = process.platform === 'win32'
|
|
143
|
-
? `where ${prov.detect}`
|
|
144
|
-
: `which ${prov.detect}`;
|
|
145
|
-
execSync(cmd, { stdio: 'pipe' });
|
|
146
|
-
available.push(id);
|
|
147
|
-
} catch {
|
|
148
|
-
// Not installed
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return available;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Build the selection menu for init
|
|
157
|
-
* Groups CLIs first (with detection status), then IDEs
|
|
158
|
-
* @returns {{ label: string, value: string }[]}
|
|
159
|
-
*/
|
|
160
|
-
export function buildProviderChoices(versionCache = {}) {
|
|
161
|
-
const detected = detectAvailable();
|
|
162
|
-
const choices = [];
|
|
163
|
-
|
|
164
|
-
// CLI providers first with detection indicator + version info
|
|
165
|
-
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
166
|
-
if (prov.type === 'cli') {
|
|
167
|
-
const installed = detected.includes(id);
|
|
168
|
-
let tag = installed ? ' ✓ detected' : '';
|
|
169
|
-
|
|
170
|
-
// Append version info if available in cache
|
|
171
|
-
const vi = versionCache[id];
|
|
172
|
-
if (vi && vi.current) {
|
|
173
|
-
if (vi.updateAvailable) {
|
|
174
|
-
tag += ` (v${vi.current} → v${vi.latest} available)`;
|
|
175
|
-
} else if (vi.latest) {
|
|
176
|
-
tag += ` (v${vi.current} ✓ latest)`;
|
|
177
|
-
} else {
|
|
178
|
-
tag += ` (v${vi.current})`;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
choices.push({
|
|
183
|
-
label: `${prov.name}${tag} — ${prov.description}`,
|
|
184
|
-
value: id,
|
|
185
|
-
available: installed,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// IDE providers — show auto-paste capability if recipe exists
|
|
191
|
-
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
192
|
-
if (prov.type === 'ide') {
|
|
193
|
-
const hasAutoPaste = automator.hasRecipe(id);
|
|
194
|
-
const tag = hasAutoPaste ? 'auto-paste' : 'clipboard + manual';
|
|
195
|
-
choices.push({
|
|
196
|
-
label: `${prov.name} — ${tag}`,
|
|
197
|
-
value: id,
|
|
198
|
-
available: true,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return choices;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Execute a prompt using the configured provider
|
|
208
|
-
*
|
|
209
|
-
* CLI providers: auto-execute via their specific command
|
|
210
|
-
* IDE providers: copy to clipboard + display instructions + wait
|
|
211
|
-
*
|
|
212
|
-
* @param {string} providerId - Provider ID from config
|
|
213
|
-
* @param {string} promptPath - Absolute path to prompt file
|
|
214
|
-
* @param {string} cwd - Working directory
|
|
215
|
-
* @returns {Promise<{ ok: boolean, rateLimited?: boolean, retryAt?: Date }>}
|
|
216
|
-
*/
|
|
217
|
-
export async function executePrompt(providerId, promptPath, cwd) {
|
|
218
|
-
const prov = PROVIDERS[providerId];
|
|
219
|
-
|
|
220
|
-
if (!prov) {
|
|
221
|
-
log.warn(`Unknown provider '${providerId}', falling back to clipboard mode`);
|
|
222
|
-
const ok = await clipboardFallback(promptPath);
|
|
223
|
-
return { ok };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (prov.type === 'cli') {
|
|
227
|
-
return await executeCLI(prov, providerId, promptPath, cwd);
|
|
228
|
-
} else {
|
|
229
|
-
const ok = await executeIDE(prov, providerId, promptPath);
|
|
230
|
-
return { ok };
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Execute via CLI provider — each tool has its own command pattern.
|
|
236
|
-
* Output is captured and filtered to show only file-level progress,
|
|
237
|
-
* keeping the terminal clean (like Claude Code's compact display).
|
|
238
|
-
*/
|
|
239
|
-
async function executeCLI(prov, providerId, promptPath, cwd) {
|
|
240
|
-
// Verify the CLI is still available
|
|
241
|
-
if (prov.detect) {
|
|
242
|
-
try {
|
|
243
|
-
const cmd = process.platform === 'win32'
|
|
244
|
-
? `where ${prov.detect}`
|
|
245
|
-
: `which ${prov.detect}`;
|
|
246
|
-
execSync(cmd, { stdio: 'pipe' });
|
|
247
|
-
} catch {
|
|
248
|
-
log.warn(`${prov.name} not found in PATH, falling back to clipboard mode`);
|
|
249
|
-
const ok = await clipboardFallback(promptPath);
|
|
250
|
-
return { ok };
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const command = prov.buildCommand(promptPath, cwd);
|
|
255
|
-
log.info(`Executing via ${prov.name}...`);
|
|
256
|
-
log.dim(` \u2192 ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
|
|
257
|
-
|
|
258
|
-
return new Promise((resolvePromise) => {
|
|
259
|
-
const child = spawn('sh', ['-c', command], {
|
|
260
|
-
cwd,
|
|
261
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
let lastFile = '';
|
|
265
|
-
let statusText = command.substring(0, 80);
|
|
266
|
-
let lineBuffer = '';
|
|
267
|
-
let stderrBuffer = ''; // Capture stderr for rate limit detection
|
|
268
|
-
const FILE_EXT = /(?:^|\s|['"|(/])([a-zA-Z0-9_.\/-]+\.(?:rs|ts|js|jsx|tsx|py|go|toml|yaml|yml|json|md|css|html|sh|sql|prisma|vue|svelte))\b/;
|
|
269
|
-
|
|
270
|
-
// Spinner animation — gives a dynamic, alive feel
|
|
271
|
-
const SPINNER = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
|
|
272
|
-
let spinIdx = 0;
|
|
273
|
-
const spinTimer = setInterval(() => {
|
|
274
|
-
const frame = SPINNER[spinIdx % SPINNER.length];
|
|
275
|
-
process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m ${statusText}`);
|
|
276
|
-
spinIdx++;
|
|
277
|
-
}, 80);
|
|
278
|
-
|
|
279
|
-
function processLine(line) {
|
|
280
|
-
const fileMatch = line.match(FILE_EXT);
|
|
281
|
-
if (fileMatch && fileMatch[1] !== lastFile) {
|
|
282
|
-
lastFile = fileMatch[1];
|
|
283
|
-
statusText = lastFile;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
child.stdout.on('data', (data) => {
|
|
288
|
-
const text = data.toString();
|
|
289
|
-
lineBuffer += text;
|
|
290
|
-
stderrBuffer += text; // Also check stdout for rate limit messages
|
|
291
|
-
const lines = lineBuffer.split('\n');
|
|
292
|
-
lineBuffer = lines.pop();
|
|
293
|
-
for (const line of lines) processLine(line);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
child.stderr.on('data', (data) => {
|
|
297
|
-
const text = data.toString();
|
|
298
|
-
stderrBuffer += text;
|
|
299
|
-
const trimmed = text.trim();
|
|
300
|
-
if (trimmed && !trimmed.includes('\u2588') && !trimmed.includes('progress')) {
|
|
301
|
-
for (const line of trimmed.split('\n').slice(0, 3)) {
|
|
302
|
-
if (line.trim()) log.dim(` ${line.substring(0, 120)}`);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
child.on('close', (code) => {
|
|
308
|
-
clearInterval(spinTimer);
|
|
309
|
-
process.stdout.write('\r\x1b[K');
|
|
310
|
-
if (code === 0) {
|
|
311
|
-
log.info(`${prov.name} execution complete`);
|
|
312
|
-
resolvePromise({ ok: true });
|
|
313
|
-
} else {
|
|
314
|
-
// Check for rate limit error in captured output
|
|
315
|
-
const rateLimitInfo = parseRateLimitError(stderrBuffer);
|
|
316
|
-
if (rateLimitInfo) {
|
|
317
|
-
log.warn(`${prov.name} hit rate limit — retry at ${rateLimitInfo.retryAtStr}`);
|
|
318
|
-
resolvePromise({ ok: false, rateLimited: true, retryAt: rateLimitInfo.retryAt, retryAtStr: rateLimitInfo.retryAtStr });
|
|
319
|
-
} else {
|
|
320
|
-
log.warn(`${prov.name} exited with code ${code}`);
|
|
321
|
-
resolvePromise({ ok: false });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
child.on('error', (err) => {
|
|
327
|
-
clearInterval(spinTimer);
|
|
328
|
-
process.stdout.write('\r\x1b[K');
|
|
329
|
-
log.warn(`${prov.name} execution failed: ${err.message}`);
|
|
330
|
-
resolvePromise({ ok: false });
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Execute via IDE provider
|
|
337
|
-
*
|
|
338
|
-
* Priority: auto-paste (if IDE running) → manual clipboard fallback
|
|
339
|
-
*/
|
|
340
|
-
async function executeIDE(prov, providerId, promptPath) {
|
|
341
|
-
const prompt = readFileSync(promptPath, 'utf-8');
|
|
342
|
-
|
|
343
|
-
// ── Try auto-paste first ──
|
|
344
|
-
if (automator.hasRecipe(providerId)) {
|
|
345
|
-
if (automator.isIDERunning(providerId)) {
|
|
346
|
-
log.info(`${prov.name} detected — attempting auto-paste...`);
|
|
347
|
-
const ok = automator.activateAndPaste(providerId, prompt);
|
|
348
|
-
if (ok) {
|
|
349
|
-
log.info(`✅ Prompt auto-pasted into ${prov.name}`);
|
|
350
|
-
log.blank();
|
|
351
|
-
await ask(`Press Enter after ${prov.name} development is complete...`);
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
log.warn('Auto-paste failed, falling back to manual mode');
|
|
355
|
-
} else {
|
|
356
|
-
log.warn(`${prov.name} is not running — using clipboard mode`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ── Manual fallback ──
|
|
361
|
-
log.blank();
|
|
362
|
-
log.info(`📋 ${prov.instructions}`);
|
|
363
|
-
log.dim(` Prompt file: ${promptPath}`);
|
|
364
|
-
log.blank();
|
|
365
|
-
|
|
366
|
-
// Some providers benefit from seeing the prompt in terminal
|
|
367
|
-
if (prov.displayPrompt) {
|
|
368
|
-
const lines = prompt.split('\n');
|
|
369
|
-
const maxLines = 30;
|
|
370
|
-
const show = lines.slice(0, maxLines);
|
|
371
|
-
console.log(' ┌─── Prompt Preview ─────────────────────────────────────┐');
|
|
372
|
-
for (const line of show) {
|
|
373
|
-
console.log(` │ ${line}`);
|
|
374
|
-
}
|
|
375
|
-
if (lines.length > maxLines) {
|
|
376
|
-
console.log(` │ ... (${lines.length - maxLines} more lines — see full file)`);
|
|
377
|
-
}
|
|
378
|
-
console.log(' └────────────────────────────────────────────────────────┘');
|
|
379
|
-
log.blank();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
copyToClipboard(prompt);
|
|
383
|
-
|
|
384
|
-
await ask(`Press Enter after ${prov.name} development is complete...`);
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Fallback: copy to clipboard and wait for any provider
|
|
390
|
-
*/
|
|
391
|
-
async function clipboardFallback(promptPath) {
|
|
392
|
-
const prompt = readFileSync(promptPath, 'utf-8');
|
|
393
|
-
|
|
394
|
-
log.blank();
|
|
395
|
-
log.info('📋 Paste the prompt into your AI coding tool and execute');
|
|
396
|
-
log.dim(` Prompt file: ${promptPath}`);
|
|
397
|
-
log.blank();
|
|
398
|
-
|
|
399
|
-
copyToClipboard(prompt);
|
|
400
|
-
|
|
401
|
-
await ask('Press Enter after development is complete...');
|
|
402
|
-
return true;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function copyToClipboard(text) {
|
|
406
|
-
try {
|
|
407
|
-
if (process.platform === 'darwin') {
|
|
408
|
-
execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
409
|
-
} else if (process.platform === 'linux') {
|
|
410
|
-
// Try xclip first, then xsel
|
|
411
|
-
try {
|
|
412
|
-
execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
413
|
-
} catch {
|
|
414
|
-
execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* @param {string}
|
|
434
|
-
* @param {string}
|
|
435
|
-
* @
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* '
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
* @param {string}
|
|
481
|
-
* @param {string}
|
|
482
|
-
* @
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
- If the review
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (upper.includes('
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
*
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
if (bv
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
*
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
*
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
*
|
|
595
|
-
* @
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
*
|
|
640
|
-
* @
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* @
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
* @
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
{
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1
|
+
/**
|
|
2
|
+
* AI Provider Registry — supports multiple AI coding tools
|
|
3
|
+
*
|
|
4
|
+
* Each provider has its own invocation pattern:
|
|
5
|
+
* - Codex CLI: piped stdin → codex exec --full-auto
|
|
6
|
+
* - Claude Code: -p flag + --allowedTools for safe auto-execution
|
|
7
|
+
* - Cursor Agent: cursor-agent CLI with -p headless mode
|
|
8
|
+
* - Gemini CLI: gemini -p for non-interactive prompt
|
|
9
|
+
* - Codex Desktop / Cursor IDE / Antigravity IDE: clipboard + manual
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync, spawn } from 'child_process';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
14
|
+
import { resolve } from 'path';
|
|
15
|
+
import { log } from './logger.js';
|
|
16
|
+
import { ask } from './prompt.js';
|
|
17
|
+
import { automator } from './automator.js';
|
|
18
|
+
|
|
19
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
20
|
+
|
|
21
|
+
// ──────────────────────────────────────────────
|
|
22
|
+
// Provider Registry — each provider is unique
|
|
23
|
+
// ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const PROVIDERS = {
|
|
26
|
+
// ─── CLI Providers (auto-execute) ───
|
|
27
|
+
|
|
28
|
+
'codex-cli': {
|
|
29
|
+
name: 'Codex CLI',
|
|
30
|
+
type: 'cli',
|
|
31
|
+
detect: 'codex',
|
|
32
|
+
// Codex uses piped stdin: cat file | codex exec --full-auto -
|
|
33
|
+
buildCommand: (promptPath, cwd) =>
|
|
34
|
+
`cat ${shellEscape(promptPath)} | codex exec --full-auto -`,
|
|
35
|
+
description: 'OpenAI Codex CLI — pipes prompt via stdin',
|
|
36
|
+
version: {
|
|
37
|
+
command: 'codex --version',
|
|
38
|
+
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
39
|
+
latest: { type: 'brew-cask', name: 'codex' },
|
|
40
|
+
update: 'brew upgrade --cask codex',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
'claude-code': {
|
|
45
|
+
name: 'Claude Code',
|
|
46
|
+
type: 'cli',
|
|
47
|
+
detect: 'claude',
|
|
48
|
+
// Claude Code uses -p (print/non-interactive) + --allowedTools for permissions
|
|
49
|
+
// Reads the file content and passes as argument (not piped)
|
|
50
|
+
buildCommand: (promptPath, cwd) => {
|
|
51
|
+
const escaped = shellEscape(promptPath);
|
|
52
|
+
// Use subshell to read file content into -p argument
|
|
53
|
+
return `claude -p "$(cat ${escaped})" --allowedTools "Bash(git*),Read,Write,Edit"`;
|
|
54
|
+
},
|
|
55
|
+
description: 'Anthropic Claude Code CLI — -p mode with tool permissions',
|
|
56
|
+
version: {
|
|
57
|
+
command: 'claude --version',
|
|
58
|
+
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
59
|
+
latest: { type: 'npm', name: '@anthropic-ai/claude-code' },
|
|
60
|
+
update: 'npm update -g @anthropic-ai/claude-code',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
'cursor-agent': {
|
|
65
|
+
name: 'Cursor Agent',
|
|
66
|
+
type: 'cli',
|
|
67
|
+
detect: 'cursor-agent',
|
|
68
|
+
// Cursor Agent uses -p flag for headless/non-interactive mode
|
|
69
|
+
buildCommand: (promptPath, cwd) => {
|
|
70
|
+
const escaped = shellEscape(promptPath);
|
|
71
|
+
return `cursor-agent -p "$(cat ${escaped})"`;
|
|
72
|
+
},
|
|
73
|
+
description: 'Cursor Agent CLI — headless -p mode',
|
|
74
|
+
// No standard package manager — skip version check
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
'gemini-cli': {
|
|
78
|
+
name: 'Gemini CLI',
|
|
79
|
+
type: 'cli',
|
|
80
|
+
detect: 'gemini',
|
|
81
|
+
// Gemini CLI uses -p for non-interactive prompt execution
|
|
82
|
+
buildCommand: (promptPath, cwd) => {
|
|
83
|
+
const escaped = shellEscape(promptPath);
|
|
84
|
+
return `gemini -p "$(cat ${escaped})"`;
|
|
85
|
+
},
|
|
86
|
+
description: 'Google Gemini CLI — non-interactive -p mode',
|
|
87
|
+
version: {
|
|
88
|
+
command: 'gemini --version',
|
|
89
|
+
parse: (output) => output.match(/[\d.]+/)?.[0],
|
|
90
|
+
latest: { type: 'brew', name: 'gemini-cli' },
|
|
91
|
+
update: 'brew upgrade gemini-cli',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// ─── IDE Providers (clipboard + manual) ───
|
|
96
|
+
|
|
97
|
+
'codex-desktop': {
|
|
98
|
+
name: 'Codex Desktop',
|
|
99
|
+
type: 'ide',
|
|
100
|
+
instructions: 'Open Codex Desktop → paste the prompt → execute',
|
|
101
|
+
displayPrompt: true, // Show the prompt content in terminal box
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
'cursor': {
|
|
105
|
+
name: 'Cursor IDE',
|
|
106
|
+
type: 'ide',
|
|
107
|
+
instructions: 'Open Cursor → Ctrl/Cmd+I (Composer) → paste the prompt → run as Agent',
|
|
108
|
+
displayPrompt: false, // Too long to display, just copy to clipboard
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
'antigravity': {
|
|
112
|
+
name: 'Antigravity (Gemini)',
|
|
113
|
+
type: 'ide',
|
|
114
|
+
instructions: 'Open Antigravity → paste the prompt into chat → execute',
|
|
115
|
+
displayPrompt: false,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get a provider by ID
|
|
121
|
+
*/
|
|
122
|
+
export function getProvider(id) {
|
|
123
|
+
return PROVIDERS[id] || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get all provider IDs
|
|
128
|
+
*/
|
|
129
|
+
export function getAllProviderIds() {
|
|
130
|
+
return Object.keys(PROVIDERS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detect which CLI providers are installed on the system
|
|
135
|
+
* @returns {string[]} list of available provider IDs
|
|
136
|
+
*/
|
|
137
|
+
export function detectAvailable() {
|
|
138
|
+
const available = [];
|
|
139
|
+
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
140
|
+
if (prov.type === 'cli' && prov.detect) {
|
|
141
|
+
try {
|
|
142
|
+
const cmd = process.platform === 'win32'
|
|
143
|
+
? `where ${prov.detect}`
|
|
144
|
+
: `which ${prov.detect}`;
|
|
145
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
146
|
+
available.push(id);
|
|
147
|
+
} catch {
|
|
148
|
+
// Not installed
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return available;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the selection menu for init
|
|
157
|
+
* Groups CLIs first (with detection status), then IDEs
|
|
158
|
+
* @returns {{ label: string, value: string }[]}
|
|
159
|
+
*/
|
|
160
|
+
export function buildProviderChoices(versionCache = {}) {
|
|
161
|
+
const detected = detectAvailable();
|
|
162
|
+
const choices = [];
|
|
163
|
+
|
|
164
|
+
// CLI providers first with detection indicator + version info
|
|
165
|
+
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
166
|
+
if (prov.type === 'cli') {
|
|
167
|
+
const installed = detected.includes(id);
|
|
168
|
+
let tag = installed ? ' ✓ detected' : '';
|
|
169
|
+
|
|
170
|
+
// Append version info if available in cache
|
|
171
|
+
const vi = versionCache[id];
|
|
172
|
+
if (vi && vi.current) {
|
|
173
|
+
if (vi.updateAvailable) {
|
|
174
|
+
tag += ` (v${vi.current} → v${vi.latest} available)`;
|
|
175
|
+
} else if (vi.latest) {
|
|
176
|
+
tag += ` (v${vi.current} ✓ latest)`;
|
|
177
|
+
} else {
|
|
178
|
+
tag += ` (v${vi.current})`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
choices.push({
|
|
183
|
+
label: `${prov.name}${tag} — ${prov.description}`,
|
|
184
|
+
value: id,
|
|
185
|
+
available: installed,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// IDE providers — show auto-paste capability if recipe exists
|
|
191
|
+
for (const [id, prov] of Object.entries(PROVIDERS)) {
|
|
192
|
+
if (prov.type === 'ide') {
|
|
193
|
+
const hasAutoPaste = automator.hasRecipe(id);
|
|
194
|
+
const tag = hasAutoPaste ? 'auto-paste' : 'clipboard + manual';
|
|
195
|
+
choices.push({
|
|
196
|
+
label: `${prov.name} — ${tag}`,
|
|
197
|
+
value: id,
|
|
198
|
+
available: true,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return choices;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Execute a prompt using the configured provider
|
|
208
|
+
*
|
|
209
|
+
* CLI providers: auto-execute via their specific command
|
|
210
|
+
* IDE providers: copy to clipboard + display instructions + wait
|
|
211
|
+
*
|
|
212
|
+
* @param {string} providerId - Provider ID from config
|
|
213
|
+
* @param {string} promptPath - Absolute path to prompt file
|
|
214
|
+
* @param {string} cwd - Working directory
|
|
215
|
+
* @returns {Promise<{ ok: boolean, rateLimited?: boolean, retryAt?: Date }>}
|
|
216
|
+
*/
|
|
217
|
+
export async function executePrompt(providerId, promptPath, cwd) {
|
|
218
|
+
const prov = PROVIDERS[providerId];
|
|
219
|
+
|
|
220
|
+
if (!prov) {
|
|
221
|
+
log.warn(`Unknown provider '${providerId}', falling back to clipboard mode`);
|
|
222
|
+
const ok = await clipboardFallback(promptPath);
|
|
223
|
+
return { ok };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (prov.type === 'cli') {
|
|
227
|
+
return await executeCLI(prov, providerId, promptPath, cwd);
|
|
228
|
+
} else {
|
|
229
|
+
const ok = await executeIDE(prov, providerId, promptPath);
|
|
230
|
+
return { ok };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Execute via CLI provider — each tool has its own command pattern.
|
|
236
|
+
* Output is captured and filtered to show only file-level progress,
|
|
237
|
+
* keeping the terminal clean (like Claude Code's compact display).
|
|
238
|
+
*/
|
|
239
|
+
async function executeCLI(prov, providerId, promptPath, cwd) {
|
|
240
|
+
// Verify the CLI is still available
|
|
241
|
+
if (prov.detect) {
|
|
242
|
+
try {
|
|
243
|
+
const cmd = process.platform === 'win32'
|
|
244
|
+
? `where ${prov.detect}`
|
|
245
|
+
: `which ${prov.detect}`;
|
|
246
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
247
|
+
} catch {
|
|
248
|
+
log.warn(`${prov.name} not found in PATH, falling back to clipboard mode`);
|
|
249
|
+
const ok = await clipboardFallback(promptPath);
|
|
250
|
+
return { ok };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const command = prov.buildCommand(promptPath, cwd);
|
|
255
|
+
log.info(`Executing via ${prov.name}...`);
|
|
256
|
+
log.dim(` \u2192 ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
|
|
257
|
+
|
|
258
|
+
return new Promise((resolvePromise) => {
|
|
259
|
+
const child = spawn('sh', ['-c', command], {
|
|
260
|
+
cwd,
|
|
261
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
let lastFile = '';
|
|
265
|
+
let statusText = command.substring(0, 80);
|
|
266
|
+
let lineBuffer = '';
|
|
267
|
+
let stderrBuffer = ''; // Capture stderr for rate limit detection
|
|
268
|
+
const FILE_EXT = /(?:^|\s|['"|(/])([a-zA-Z0-9_.\/-]+\.(?:rs|ts|js|jsx|tsx|py|go|toml|yaml|yml|json|md|css|html|sh|sql|prisma|vue|svelte))\b/;
|
|
269
|
+
|
|
270
|
+
// Spinner animation — gives a dynamic, alive feel
|
|
271
|
+
const SPINNER = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
|
|
272
|
+
let spinIdx = 0;
|
|
273
|
+
const spinTimer = setInterval(() => {
|
|
274
|
+
const frame = SPINNER[spinIdx % SPINNER.length];
|
|
275
|
+
process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m ${statusText}`);
|
|
276
|
+
spinIdx++;
|
|
277
|
+
}, 80);
|
|
278
|
+
|
|
279
|
+
function processLine(line) {
|
|
280
|
+
const fileMatch = line.match(FILE_EXT);
|
|
281
|
+
if (fileMatch && fileMatch[1] !== lastFile) {
|
|
282
|
+
lastFile = fileMatch[1];
|
|
283
|
+
statusText = lastFile;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
child.stdout.on('data', (data) => {
|
|
288
|
+
const text = data.toString();
|
|
289
|
+
lineBuffer += text;
|
|
290
|
+
stderrBuffer += text; // Also check stdout for rate limit messages
|
|
291
|
+
const lines = lineBuffer.split('\n');
|
|
292
|
+
lineBuffer = lines.pop();
|
|
293
|
+
for (const line of lines) processLine(line);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
child.stderr.on('data', (data) => {
|
|
297
|
+
const text = data.toString();
|
|
298
|
+
stderrBuffer += text;
|
|
299
|
+
const trimmed = text.trim();
|
|
300
|
+
if (trimmed && !trimmed.includes('\u2588') && !trimmed.includes('progress')) {
|
|
301
|
+
for (const line of trimmed.split('\n').slice(0, 3)) {
|
|
302
|
+
if (line.trim()) log.dim(` ${line.substring(0, 120)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
child.on('close', (code) => {
|
|
308
|
+
clearInterval(spinTimer);
|
|
309
|
+
process.stdout.write('\r\x1b[K');
|
|
310
|
+
if (code === 0) {
|
|
311
|
+
log.info(`${prov.name} execution complete`);
|
|
312
|
+
resolvePromise({ ok: true });
|
|
313
|
+
} else {
|
|
314
|
+
// Check for rate limit error in captured output
|
|
315
|
+
const rateLimitInfo = parseRateLimitError(stderrBuffer);
|
|
316
|
+
if (rateLimitInfo) {
|
|
317
|
+
log.warn(`${prov.name} hit rate limit — retry at ${rateLimitInfo.retryAtStr}`);
|
|
318
|
+
resolvePromise({ ok: false, rateLimited: true, retryAt: rateLimitInfo.retryAt, retryAtStr: rateLimitInfo.retryAtStr });
|
|
319
|
+
} else {
|
|
320
|
+
log.warn(`${prov.name} exited with code ${code}`);
|
|
321
|
+
resolvePromise({ ok: false });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
child.on('error', (err) => {
|
|
327
|
+
clearInterval(spinTimer);
|
|
328
|
+
process.stdout.write('\r\x1b[K');
|
|
329
|
+
log.warn(`${prov.name} execution failed: ${err.message}`);
|
|
330
|
+
resolvePromise({ ok: false });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Execute via IDE provider
|
|
337
|
+
*
|
|
338
|
+
* Priority: auto-paste (if IDE running) → manual clipboard fallback
|
|
339
|
+
*/
|
|
340
|
+
async function executeIDE(prov, providerId, promptPath) {
|
|
341
|
+
const prompt = readFileSync(promptPath, 'utf-8');
|
|
342
|
+
|
|
343
|
+
// ── Try auto-paste first ──
|
|
344
|
+
if (automator.hasRecipe(providerId)) {
|
|
345
|
+
if (automator.isIDERunning(providerId)) {
|
|
346
|
+
log.info(`${prov.name} detected — attempting auto-paste...`);
|
|
347
|
+
const ok = automator.activateAndPaste(providerId, prompt);
|
|
348
|
+
if (ok) {
|
|
349
|
+
log.info(`✅ Prompt auto-pasted into ${prov.name}`);
|
|
350
|
+
log.blank();
|
|
351
|
+
await ask(`Press Enter after ${prov.name} development is complete...`);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
log.warn('Auto-paste failed, falling back to manual mode');
|
|
355
|
+
} else {
|
|
356
|
+
log.warn(`${prov.name} is not running — using clipboard mode`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Manual fallback ──
|
|
361
|
+
log.blank();
|
|
362
|
+
log.info(`📋 ${prov.instructions}`);
|
|
363
|
+
log.dim(` Prompt file: ${promptPath}`);
|
|
364
|
+
log.blank();
|
|
365
|
+
|
|
366
|
+
// Some providers benefit from seeing the prompt in terminal
|
|
367
|
+
if (prov.displayPrompt) {
|
|
368
|
+
const lines = prompt.split('\n');
|
|
369
|
+
const maxLines = 30;
|
|
370
|
+
const show = lines.slice(0, maxLines);
|
|
371
|
+
console.log(' ┌─── Prompt Preview ─────────────────────────────────────┐');
|
|
372
|
+
for (const line of show) {
|
|
373
|
+
console.log(` │ ${line}`);
|
|
374
|
+
}
|
|
375
|
+
if (lines.length > maxLines) {
|
|
376
|
+
console.log(` │ ... (${lines.length - maxLines} more lines — see full file)`);
|
|
377
|
+
}
|
|
378
|
+
console.log(' └────────────────────────────────────────────────────────┘');
|
|
379
|
+
log.blank();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
copyToClipboard(prompt);
|
|
383
|
+
|
|
384
|
+
await ask(`Press Enter after ${prov.name} development is complete...`);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Fallback: copy to clipboard and wait for any provider
|
|
390
|
+
*/
|
|
391
|
+
async function clipboardFallback(promptPath) {
|
|
392
|
+
const prompt = readFileSync(promptPath, 'utf-8');
|
|
393
|
+
|
|
394
|
+
log.blank();
|
|
395
|
+
log.info('📋 Paste the prompt into your AI coding tool and execute');
|
|
396
|
+
log.dim(` Prompt file: ${promptPath}`);
|
|
397
|
+
log.blank();
|
|
398
|
+
|
|
399
|
+
copyToClipboard(prompt);
|
|
400
|
+
|
|
401
|
+
await ask('Press Enter after development is complete...');
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function copyToClipboard(text) {
|
|
406
|
+
try {
|
|
407
|
+
if (process.platform === 'darwin') {
|
|
408
|
+
execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
409
|
+
} else if (process.platform === 'linux') {
|
|
410
|
+
// Try xclip first, then xsel
|
|
411
|
+
try {
|
|
412
|
+
execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
413
|
+
} catch {
|
|
414
|
+
execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
415
|
+
}
|
|
416
|
+
} else if (process.platform === 'win32') {
|
|
417
|
+
execSync('clip', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
418
|
+
}
|
|
419
|
+
log.info('📋 Copied to clipboard');
|
|
420
|
+
} catch {
|
|
421
|
+
log.dim('(Could not copy to clipboard — copy the file manually)');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function shellEscape(str) {
|
|
426
|
+
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Lightweight AI query — capture output rather than inherit stdio.
|
|
431
|
+
* Only works for CLI providers. Returns null for IDE providers.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} providerId - Provider ID
|
|
434
|
+
* @param {string} question - The question/prompt text
|
|
435
|
+
* @param {string} cwd - Working directory
|
|
436
|
+
* @returns {Promise<string|null>} AI response text, or null if unsupported/failed
|
|
437
|
+
*/
|
|
438
|
+
export async function queryAI(providerId, question, cwd) {
|
|
439
|
+
const prov = PROVIDERS[providerId];
|
|
440
|
+
if (!prov || prov.type !== 'cli') return null;
|
|
441
|
+
|
|
442
|
+
// Verify CLI is available
|
|
443
|
+
if (prov.detect) {
|
|
444
|
+
try {
|
|
445
|
+
const cmd = process.platform === 'win32'
|
|
446
|
+
? `where ${prov.detect}`
|
|
447
|
+
: `which ${prov.detect}`;
|
|
448
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Write question to temp file
|
|
455
|
+
const tmpPath = resolve(cwd, '.codex-copilot/_query_prompt.md');
|
|
456
|
+
writeFileSync(tmpPath, question);
|
|
457
|
+
|
|
458
|
+
const command = prov.buildCommand(tmpPath, cwd);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const output = execSync(command, {
|
|
462
|
+
cwd,
|
|
463
|
+
encoding: 'utf-8',
|
|
464
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
465
|
+
timeout: 60000, // 60s timeout for classification
|
|
466
|
+
});
|
|
467
|
+
return output.trim();
|
|
468
|
+
} catch (err) {
|
|
469
|
+
log.dim(`AI query failed: ${(err.message || '').substring(0, 80)}`);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Use AI to classify code review feedback.
|
|
476
|
+
*
|
|
477
|
+
* Returns 'pass' if AI determines no actionable issues,
|
|
478
|
+
* 'fix' if there are issues to address, or null if classification failed.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} providerId - Provider ID
|
|
481
|
+
* @param {string} feedbackText - The collected review feedback
|
|
482
|
+
* @param {string} cwd - Working directory
|
|
483
|
+
* @returns {Promise<'pass'|'fix'|null>}
|
|
484
|
+
*/
|
|
485
|
+
export async function classifyReview(providerId, feedbackText, cwd) {
|
|
486
|
+
const classificationPrompt = `You are a code review classifier. Your ONLY job is to determine if the following code review feedback contains actionable issues that require code changes.
|
|
487
|
+
|
|
488
|
+
## Code Review Feedback
|
|
489
|
+
${feedbackText}
|
|
490
|
+
|
|
491
|
+
## Instructions
|
|
492
|
+
- If the review says the code looks good, has no issues, is purely informational, or explicitly states no changes are needed: output exactly PASS
|
|
493
|
+
- If the review requests specific code changes, points out bugs, security issues, or improvements that need action: output exactly FIX
|
|
494
|
+
|
|
495
|
+
IMPORTANT: Output ONLY a single word on the first line: either PASS or FIX. No other text.`;
|
|
496
|
+
|
|
497
|
+
const response = await queryAI(providerId, classificationPrompt, cwd);
|
|
498
|
+
if (!response) return null;
|
|
499
|
+
|
|
500
|
+
// Parse the first meaningful line
|
|
501
|
+
const firstLine = response.split('\n').map(l => l.trim()).find(l => l.length > 0);
|
|
502
|
+
if (!firstLine) return null;
|
|
503
|
+
|
|
504
|
+
const upper = firstLine.toUpperCase();
|
|
505
|
+
if (upper.includes('PASS')) return 'pass';
|
|
506
|
+
if (upper.includes('FIX')) return 'fix';
|
|
507
|
+
|
|
508
|
+
return null; // Ambiguous — caller decides fallback
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ──────────────────────────────────────────────
|
|
512
|
+
// Version Check & Auto-Update
|
|
513
|
+
// ──────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Compare two semver-like version strings.
|
|
517
|
+
* Returns true if latest > current.
|
|
518
|
+
*/
|
|
519
|
+
function isNewerVersion(current, latest) {
|
|
520
|
+
if (!current || !latest) return false;
|
|
521
|
+
const a = current.split('.').map(Number);
|
|
522
|
+
const b = latest.split('.').map(Number);
|
|
523
|
+
const len = Math.max(a.length, b.length);
|
|
524
|
+
for (let i = 0; i < len; i++) {
|
|
525
|
+
const av = a[i] || 0;
|
|
526
|
+
const bv = b[i] || 0;
|
|
527
|
+
if (bv > av) return true;
|
|
528
|
+
if (bv < av) return false;
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get the locally installed version of a provider.
|
|
535
|
+
* @returns {string|null} version string or null
|
|
536
|
+
*/
|
|
537
|
+
function getLocalVersion(providerId) {
|
|
538
|
+
const prov = PROVIDERS[providerId];
|
|
539
|
+
if (!prov?.version) return null;
|
|
540
|
+
try {
|
|
541
|
+
const output = execSync(prov.version.command, {
|
|
542
|
+
encoding: 'utf-8',
|
|
543
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
544
|
+
timeout: 5000,
|
|
545
|
+
}).trim();
|
|
546
|
+
return prov.version.parse(output);
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Fetch the latest available version from the package registry.
|
|
554
|
+
* @returns {string|null} latest version string or null
|
|
555
|
+
*/
|
|
556
|
+
function fetchLatestVersion(providerId) {
|
|
557
|
+
const prov = PROVIDERS[providerId];
|
|
558
|
+
if (!prov?.version?.latest) return null;
|
|
559
|
+
|
|
560
|
+
const { type, name } = prov.version.latest;
|
|
561
|
+
try {
|
|
562
|
+
if (type === 'npm') {
|
|
563
|
+
return execSync(`npm view ${name} version`, {
|
|
564
|
+
encoding: 'utf-8',
|
|
565
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
566
|
+
timeout: 10000,
|
|
567
|
+
}).trim();
|
|
568
|
+
}
|
|
569
|
+
if (type === 'brew') {
|
|
570
|
+
const json = execSync(`brew info ${name} --json=v2`, {
|
|
571
|
+
encoding: 'utf-8',
|
|
572
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
573
|
+
timeout: 10000,
|
|
574
|
+
});
|
|
575
|
+
const data = JSON.parse(json);
|
|
576
|
+
return data.formulae?.[0]?.versions?.stable || null;
|
|
577
|
+
}
|
|
578
|
+
if (type === 'brew-cask') {
|
|
579
|
+
const json = execSync(`brew info --cask ${name} --json=v2`, {
|
|
580
|
+
encoding: 'utf-8',
|
|
581
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
582
|
+
timeout: 10000,
|
|
583
|
+
});
|
|
584
|
+
const data = JSON.parse(json);
|
|
585
|
+
return data.casks?.[0]?.version || null;
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Check version status for a single provider.
|
|
595
|
+
* @param {string} providerId
|
|
596
|
+
* @returns {{ current: string|null, latest: string|null, updateAvailable: boolean, updateCommand: string|null }}
|
|
597
|
+
*/
|
|
598
|
+
export function checkProviderVersion(providerId) {
|
|
599
|
+
const prov = PROVIDERS[providerId];
|
|
600
|
+
if (!prov?.version) {
|
|
601
|
+
return { current: null, latest: null, updateAvailable: false, updateCommand: null };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const current = getLocalVersion(providerId);
|
|
605
|
+
const latest = fetchLatestVersion(providerId);
|
|
606
|
+
const updateAvailable = isNewerVersion(current, latest);
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
current,
|
|
610
|
+
latest,
|
|
611
|
+
updateAvailable,
|
|
612
|
+
updateCommand: updateAvailable ? prov.version.update : null,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check versions for all installed CLI providers in parallel.
|
|
618
|
+
* Returns a map of providerId → version info.
|
|
619
|
+
* Non-blocking: failures are silently ignored.
|
|
620
|
+
*/
|
|
621
|
+
export function checkAllVersions() {
|
|
622
|
+
const detected = detectAvailable();
|
|
623
|
+
const results = {};
|
|
624
|
+
|
|
625
|
+
for (const id of detected) {
|
|
626
|
+
const prov = PROVIDERS[id];
|
|
627
|
+
if (!prov?.version) continue;
|
|
628
|
+
try {
|
|
629
|
+
results[id] = checkProviderVersion(id);
|
|
630
|
+
} catch {
|
|
631
|
+
// Skip on failure — non-blocking
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return results;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Execute the update command for a provider.
|
|
640
|
+
* @param {string} providerId
|
|
641
|
+
* @returns {boolean} true if update succeeded
|
|
642
|
+
*/
|
|
643
|
+
export function updateProvider(providerId) {
|
|
644
|
+
const prov = PROVIDERS[providerId];
|
|
645
|
+
if (!prov?.version?.update) return false;
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
log.info(`Updating ${prov.name}...`);
|
|
649
|
+
execSync(prov.version.update, {
|
|
650
|
+
encoding: 'utf-8',
|
|
651
|
+
stdio: 'inherit',
|
|
652
|
+
timeout: 120000, // 2 minute timeout for updates
|
|
653
|
+
});
|
|
654
|
+
log.info(`✅ ${prov.name} updated successfully`);
|
|
655
|
+
return true;
|
|
656
|
+
} catch (err) {
|
|
657
|
+
log.warn(`Failed to update ${prov.name}: ${(err.message || '').substring(0, 100)}`);
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ──────────────────────────────────────────────
|
|
663
|
+
// Rate Limit Detection
|
|
664
|
+
// ──────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Parse rate limit error from CLI output.
|
|
668
|
+
* Looks for: "try again at X:XX PM" or "try again at X:XX AM"
|
|
669
|
+
* @param {string} output - Combined stdout+stderr text
|
|
670
|
+
* @returns {{ retryAt: Date, retryAtStr: string } | null}
|
|
671
|
+
*/
|
|
672
|
+
function parseRateLimitError(output) {
|
|
673
|
+
if (!output) return null;
|
|
674
|
+
|
|
675
|
+
// Match "usage limit" or "rate limit" patterns
|
|
676
|
+
const isRateLimited = /usage limit|rate limit|too many requests/i.test(output);
|
|
677
|
+
if (!isRateLimited) return null;
|
|
678
|
+
|
|
679
|
+
// Extract time: "try again at 6:06 PM" or "try again at 18:06"
|
|
680
|
+
const timeMatch = output.match(/try again at\s+(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?)/i);
|
|
681
|
+
if (!timeMatch) {
|
|
682
|
+
// Rate limited but no specific time — estimate 30 minutes from now
|
|
683
|
+
const retryAt = new Date(Date.now() + 30 * 60 * 1000);
|
|
684
|
+
return { retryAt, retryAtStr: retryAt.toLocaleTimeString() };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const timeStr = timeMatch[1].trim();
|
|
688
|
+
const now = new Date();
|
|
689
|
+
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
690
|
+
|
|
691
|
+
// Parse the time string
|
|
692
|
+
let retryAt;
|
|
693
|
+
if (/AM|PM/i.test(timeStr)) {
|
|
694
|
+
// 12-hour format: "6:06 PM"
|
|
695
|
+
retryAt = new Date(`${today} ${timeStr}`);
|
|
696
|
+
} else {
|
|
697
|
+
// 24-hour format: "18:06"
|
|
698
|
+
retryAt = new Date(`${today}T${timeStr}`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// If parsed time is in the past, it's tomorrow
|
|
702
|
+
if (retryAt <= now) {
|
|
703
|
+
retryAt.setDate(retryAt.getDate() + 1);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Sanity check: if retry is more than 6 hours away, something's wrong
|
|
707
|
+
if (retryAt - now > 6 * 60 * 60 * 1000) {
|
|
708
|
+
retryAt = new Date(Date.now() + 30 * 60 * 1000);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return { retryAt, retryAtStr: timeStr };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ──────────────────────────────────────────────
|
|
715
|
+
// Quota Pre-Check
|
|
716
|
+
// ──────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Check current Codex 7d quota usage before execution.
|
|
720
|
+
* Reads the latest rollout file for quota data.
|
|
721
|
+
*
|
|
722
|
+
* @param {number} threshold - Percentage at which to block (default 97)
|
|
723
|
+
* @returns {{ ok: boolean, quota5h: number|null, quota7d: number|null, warning: boolean }}
|
|
724
|
+
*/
|
|
725
|
+
export function checkQuotaBeforeExecution(threshold = 97) {
|
|
726
|
+
try {
|
|
727
|
+
const sessionsDir = resolve(HOME, '.codex/sessions');
|
|
728
|
+
if (!existsSync(sessionsDir)) return { ok: true, quota5h: null, quota7d: null, warning: false };
|
|
729
|
+
|
|
730
|
+
// Find the most recent rollout file
|
|
731
|
+
const raw = execSync(
|
|
732
|
+
`find "${sessionsDir}" -name "rollout-*.jsonl" -type f | sort -r | head -1`,
|
|
733
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
734
|
+
).trim();
|
|
735
|
+
if (!raw || !existsSync(raw)) return { ok: true, quota5h: null, quota7d: null, warning: false };
|
|
736
|
+
|
|
737
|
+
// Get the last token_count event with rate_limits
|
|
738
|
+
const line = execSync(
|
|
739
|
+
`grep '"rate_limits"' "${raw}" | tail -1`,
|
|
740
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
741
|
+
).trim();
|
|
742
|
+
if (!line) return { ok: true, quota5h: null, quota7d: null, warning: false };
|
|
743
|
+
|
|
744
|
+
const data = JSON.parse(line);
|
|
745
|
+
const limits = data?.payload?.rate_limits;
|
|
746
|
+
if (!limits) return { ok: true, quota5h: null, quota7d: null, warning: false };
|
|
747
|
+
|
|
748
|
+
const quota5h = limits.primary?.used_percent ?? null;
|
|
749
|
+
const quota7d = limits.secondary?.used_percent ?? null;
|
|
750
|
+
|
|
751
|
+
// Block if 7d quota exceeds threshold
|
|
752
|
+
if (quota7d !== null && quota7d >= threshold) {
|
|
753
|
+
return { ok: false, quota5h, quota7d, warning: false };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Warn if 7d quota is high (>= 95%)
|
|
757
|
+
const warning = quota7d !== null && quota7d >= 95;
|
|
758
|
+
return { ok: true, quota5h, quota7d, warning };
|
|
759
|
+
} catch {
|
|
760
|
+
// Quota check failed — don't block execution
|
|
761
|
+
return { ok: true, quota5h: null, quota7d: null, warning: false };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export const provider = {
|
|
766
|
+
getProvider, getAllProviderIds, detectAvailable,
|
|
767
|
+
buildProviderChoices, executePrompt, queryAI, classifyReview,
|
|
768
|
+
checkProviderVersion, checkAllVersions, updateProvider,
|
|
769
|
+
checkQuotaBeforeExecution,
|
|
770
|
+
};
|