@jojonax/codex-copilot 1.0.3 → 1.2.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.
@@ -0,0 +1,415 @@
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 } from 'child_process';
13
+ import { readFileSync, writeFileSync } 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
+ // ──────────────────────────────────────────────
20
+ // Provider Registry — each provider is unique
21
+ // ──────────────────────────────────────────────
22
+
23
+ const PROVIDERS = {
24
+ // ─── CLI Providers (auto-execute) ───
25
+
26
+ 'codex-cli': {
27
+ name: 'Codex CLI',
28
+ type: 'cli',
29
+ detect: 'codex',
30
+ // Codex uses piped stdin: cat file | codex exec --full-auto -
31
+ buildCommand: (promptPath, cwd) =>
32
+ `cat ${shellEscape(promptPath)} | codex exec --full-auto -`,
33
+ description: 'OpenAI Codex CLI — pipes prompt via stdin',
34
+ },
35
+
36
+ 'claude-code': {
37
+ name: 'Claude Code',
38
+ type: 'cli',
39
+ detect: 'claude',
40
+ // Claude Code uses -p (print/non-interactive) + --allowedTools for permissions
41
+ // Reads the file content and passes as argument (not piped)
42
+ buildCommand: (promptPath, cwd) => {
43
+ const escaped = shellEscape(promptPath);
44
+ // Use subshell to read file content into -p argument
45
+ return `claude -p "$(cat ${escaped})" --allowedTools "Bash(git*),Read,Write,Edit"`;
46
+ },
47
+ description: 'Anthropic Claude Code CLI — -p mode with tool permissions',
48
+ },
49
+
50
+ 'cursor-agent': {
51
+ name: 'Cursor Agent',
52
+ type: 'cli',
53
+ detect: 'cursor-agent',
54
+ // Cursor Agent uses -p flag for headless/non-interactive mode
55
+ buildCommand: (promptPath, cwd) => {
56
+ const escaped = shellEscape(promptPath);
57
+ return `cursor-agent -p "$(cat ${escaped})"`;
58
+ },
59
+ description: 'Cursor Agent CLI — headless -p mode',
60
+ },
61
+
62
+ 'gemini-cli': {
63
+ name: 'Gemini CLI',
64
+ type: 'cli',
65
+ detect: 'gemini',
66
+ // Gemini CLI uses -p for non-interactive prompt execution
67
+ buildCommand: (promptPath, cwd) => {
68
+ const escaped = shellEscape(promptPath);
69
+ return `gemini -p "$(cat ${escaped})"`;
70
+ },
71
+ description: 'Google Gemini CLI — non-interactive -p mode',
72
+ },
73
+
74
+ // ─── IDE Providers (clipboard + manual) ───
75
+
76
+ 'codex-desktop': {
77
+ name: 'Codex Desktop',
78
+ type: 'ide',
79
+ instructions: 'Open Codex Desktop → paste the prompt → execute',
80
+ displayPrompt: true, // Show the prompt content in terminal box
81
+ },
82
+
83
+ 'cursor': {
84
+ name: 'Cursor IDE',
85
+ type: 'ide',
86
+ instructions: 'Open Cursor → Ctrl/Cmd+I (Composer) → paste the prompt → run as Agent',
87
+ displayPrompt: false, // Too long to display, just copy to clipboard
88
+ },
89
+
90
+ 'antigravity': {
91
+ name: 'Antigravity (Gemini)',
92
+ type: 'ide',
93
+ instructions: 'Open Antigravity → paste the prompt into chat → execute',
94
+ displayPrompt: false,
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Get a provider by ID
100
+ */
101
+ export function getProvider(id) {
102
+ return PROVIDERS[id] || null;
103
+ }
104
+
105
+ /**
106
+ * Get all provider IDs
107
+ */
108
+ export function getAllProviderIds() {
109
+ return Object.keys(PROVIDERS);
110
+ }
111
+
112
+ /**
113
+ * Detect which CLI providers are installed on the system
114
+ * @returns {string[]} list of available provider IDs
115
+ */
116
+ export function detectAvailable() {
117
+ const available = [];
118
+ for (const [id, prov] of Object.entries(PROVIDERS)) {
119
+ if (prov.type === 'cli' && prov.detect) {
120
+ try {
121
+ const cmd = process.platform === 'win32'
122
+ ? `where ${prov.detect}`
123
+ : `which ${prov.detect}`;
124
+ execSync(cmd, { stdio: 'pipe' });
125
+ available.push(id);
126
+ } catch {
127
+ // Not installed
128
+ }
129
+ }
130
+ }
131
+ return available;
132
+ }
133
+
134
+ /**
135
+ * Build the selection menu for init
136
+ * Groups CLIs first (with detection status), then IDEs
137
+ * @returns {{ label: string, value: string }[]}
138
+ */
139
+ export function buildProviderChoices() {
140
+ const detected = detectAvailable();
141
+ const choices = [];
142
+
143
+ // CLI providers first with detection indicator
144
+ for (const [id, prov] of Object.entries(PROVIDERS)) {
145
+ if (prov.type === 'cli') {
146
+ const installed = detected.includes(id);
147
+ const tag = installed ? ' ✓ detected' : '';
148
+ choices.push({
149
+ label: `${prov.name}${tag} — ${prov.description}`,
150
+ value: id,
151
+ available: installed,
152
+ });
153
+ }
154
+ }
155
+
156
+ // IDE providers — show auto-paste capability if recipe exists
157
+ for (const [id, prov] of Object.entries(PROVIDERS)) {
158
+ if (prov.type === 'ide') {
159
+ const hasAutoPaste = automator.hasRecipe(id);
160
+ const tag = hasAutoPaste ? 'auto-paste' : 'clipboard + manual';
161
+ choices.push({
162
+ label: `${prov.name} — ${tag}`,
163
+ value: id,
164
+ available: true,
165
+ });
166
+ }
167
+ }
168
+
169
+ return choices;
170
+ }
171
+
172
+ /**
173
+ * Execute a prompt using the configured provider
174
+ *
175
+ * CLI providers: auto-execute via their specific command
176
+ * IDE providers: copy to clipboard + display instructions + wait
177
+ *
178
+ * @param {string} providerId - Provider ID from config
179
+ * @param {string} promptPath - Absolute path to prompt file
180
+ * @param {string} cwd - Working directory
181
+ * @returns {Promise<boolean>} true if execution succeeded
182
+ */
183
+ export async function executePrompt(providerId, promptPath, cwd) {
184
+ const prov = PROVIDERS[providerId];
185
+
186
+ if (!prov) {
187
+ log.warn(`Unknown provider '${providerId}', falling back to clipboard mode`);
188
+ return await clipboardFallback(promptPath);
189
+ }
190
+
191
+ if (prov.type === 'cli') {
192
+ return await executeCLI(prov, providerId, promptPath, cwd);
193
+ } else {
194
+ return await executeIDE(prov, providerId, promptPath);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Execute via CLI provider — each tool has its own command pattern
200
+ */
201
+ async function executeCLI(prov, providerId, promptPath, cwd) {
202
+ // Verify the CLI is still available
203
+ if (prov.detect) {
204
+ try {
205
+ const cmd = process.platform === 'win32'
206
+ ? `where ${prov.detect}`
207
+ : `which ${prov.detect}`;
208
+ execSync(cmd, { stdio: 'pipe' });
209
+ } catch {
210
+ log.warn(`${prov.name} not found in PATH, falling back to clipboard mode`);
211
+ return await clipboardFallback(promptPath);
212
+ }
213
+ }
214
+
215
+ const command = prov.buildCommand(promptPath, cwd);
216
+ log.info(`Executing via ${prov.name}...`);
217
+ log.dim(` → ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
218
+
219
+ try {
220
+ execSync(command, { cwd, stdio: 'inherit', timeout: 0 });
221
+ log.info(`${prov.name} execution complete`);
222
+ return true;
223
+ } catch (err) {
224
+ log.warn(`${prov.name} execution failed: ${err.message}`);
225
+
226
+ // For quota exhaustion / auth errors, give a clear message
227
+ if (err.message.includes('rate_limit') || err.message.includes('quota') ||
228
+ err.message.includes('429') || err.message.includes('insufficient')) {
229
+ log.error('⚠ Possible quota exhaustion — checkpoint saved, you can resume later');
230
+ }
231
+
232
+ log.warn('Falling back to clipboard mode');
233
+ return await clipboardFallback(promptPath);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Execute via IDE provider
239
+ *
240
+ * Priority: auto-paste (if IDE running) → manual clipboard fallback
241
+ */
242
+ async function executeIDE(prov, providerId, promptPath) {
243
+ const prompt = readFileSync(promptPath, 'utf-8');
244
+
245
+ // ── Try auto-paste first ──
246
+ if (automator.hasRecipe(providerId)) {
247
+ if (automator.isIDERunning(providerId)) {
248
+ log.info(`${prov.name} detected — attempting auto-paste...`);
249
+ const ok = automator.activateAndPaste(providerId, prompt);
250
+ if (ok) {
251
+ log.info(`✅ Prompt auto-pasted into ${prov.name}`);
252
+ log.blank();
253
+ await ask(`Press Enter after ${prov.name} development is complete...`);
254
+ return true;
255
+ }
256
+ log.warn('Auto-paste failed, falling back to manual mode');
257
+ } else {
258
+ log.warn(`${prov.name} is not running — using clipboard mode`);
259
+ }
260
+ }
261
+
262
+ // ── Manual fallback ──
263
+ log.blank();
264
+ log.info(`📋 ${prov.instructions}`);
265
+ log.dim(` Prompt file: ${promptPath}`);
266
+ log.blank();
267
+
268
+ // Some providers benefit from seeing the prompt in terminal
269
+ if (prov.displayPrompt) {
270
+ const lines = prompt.split('\n');
271
+ const maxLines = 30;
272
+ const show = lines.slice(0, maxLines);
273
+ console.log(' ┌─── Prompt Preview ─────────────────────────────────────┐');
274
+ for (const line of show) {
275
+ console.log(` │ ${line}`);
276
+ }
277
+ if (lines.length > maxLines) {
278
+ console.log(` │ ... (${lines.length - maxLines} more lines — see full file)`);
279
+ }
280
+ console.log(' └────────────────────────────────────────────────────────┘');
281
+ log.blank();
282
+ }
283
+
284
+ copyToClipboard(prompt);
285
+
286
+ await ask(`Press Enter after ${prov.name} development is complete...`);
287
+ return true;
288
+ }
289
+
290
+ /**
291
+ * Fallback: copy to clipboard and wait for any provider
292
+ */
293
+ async function clipboardFallback(promptPath) {
294
+ const prompt = readFileSync(promptPath, 'utf-8');
295
+
296
+ log.blank();
297
+ log.info('📋 Paste the prompt into your AI coding tool and execute');
298
+ log.dim(` Prompt file: ${promptPath}`);
299
+ log.blank();
300
+
301
+ copyToClipboard(prompt);
302
+
303
+ await ask('Press Enter after development is complete...');
304
+ return true;
305
+ }
306
+
307
+ function copyToClipboard(text) {
308
+ try {
309
+ if (process.platform === 'darwin') {
310
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
311
+ } else if (process.platform === 'linux') {
312
+ // Try xclip first, then xsel
313
+ try {
314
+ execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
315
+ } catch {
316
+ execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
317
+ }
318
+ }
319
+ // Windows: skip — no good CLI clipboard tool
320
+ log.info('📋 Copied to clipboard');
321
+ } catch {
322
+ log.dim('(Could not copy to clipboard — copy the file manually)');
323
+ }
324
+ }
325
+
326
+ function shellEscape(str) {
327
+ return `'${str.replace(/'/g, "'\\''")}'`;
328
+ }
329
+
330
+ /**
331
+ * Lightweight AI query — capture output rather than inherit stdio.
332
+ * Only works for CLI providers. Returns null for IDE providers.
333
+ *
334
+ * @param {string} providerId - Provider ID
335
+ * @param {string} question - The question/prompt text
336
+ * @param {string} cwd - Working directory
337
+ * @returns {Promise<string|null>} AI response text, or null if unsupported/failed
338
+ */
339
+ export async function queryAI(providerId, question, cwd) {
340
+ const prov = PROVIDERS[providerId];
341
+ if (!prov || prov.type !== 'cli') return null;
342
+
343
+ // Verify CLI is available
344
+ if (prov.detect) {
345
+ try {
346
+ const cmd = process.platform === 'win32'
347
+ ? `where ${prov.detect}`
348
+ : `which ${prov.detect}`;
349
+ execSync(cmd, { stdio: 'pipe' });
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ // Write question to temp file
356
+ const tmpPath = resolve(cwd, '.codex-copilot/_query_prompt.md');
357
+ writeFileSync(tmpPath, question);
358
+
359
+ const command = prov.buildCommand(tmpPath, cwd);
360
+
361
+ try {
362
+ const output = execSync(command, {
363
+ cwd,
364
+ encoding: 'utf-8',
365
+ stdio: ['pipe', 'pipe', 'pipe'],
366
+ timeout: 60000, // 60s timeout for classification
367
+ });
368
+ return output.trim();
369
+ } catch (err) {
370
+ log.dim(`AI query failed: ${(err.message || '').substring(0, 80)}`);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Use AI to classify code review feedback.
377
+ *
378
+ * Returns 'pass' if AI determines no actionable issues,
379
+ * 'fix' if there are issues to address, or null if classification failed.
380
+ *
381
+ * @param {string} providerId - Provider ID
382
+ * @param {string} feedbackText - The collected review feedback
383
+ * @param {string} cwd - Working directory
384
+ * @returns {Promise<'pass'|'fix'|null>}
385
+ */
386
+ export async function classifyReview(providerId, feedbackText, cwd) {
387
+ 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.
388
+
389
+ ## Code Review Feedback
390
+ ${feedbackText}
391
+
392
+ ## Instructions
393
+ - If the review says the code looks good, has no issues, is purely informational, or explicitly states no changes are needed: output exactly PASS
394
+ - If the review requests specific code changes, points out bugs, security issues, or improvements that need action: output exactly FIX
395
+
396
+ IMPORTANT: Output ONLY a single word on the first line: either PASS or FIX. No other text.`;
397
+
398
+ const response = await queryAI(providerId, classificationPrompt, cwd);
399
+ if (!response) return null;
400
+
401
+ // Parse the first meaningful line
402
+ const firstLine = response.split('\n').map(l => l.trim()).find(l => l.length > 0);
403
+ if (!firstLine) return null;
404
+
405
+ const upper = firstLine.toUpperCase();
406
+ if (upper.includes('PASS')) return 'pass';
407
+ if (upper.includes('FIX')) return 'fix';
408
+
409
+ return null; // Ambiguous — caller decides fallback
410
+ }
411
+
412
+ export const provider = {
413
+ getProvider, getAllProviderIds, detectAvailable,
414
+ buildProviderChoices, executePrompt, queryAI, classifyReview,
415
+ };