@jojonax/codex-copilot 1.0.3 → 1.1.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,279 @@
1
+ /**
2
+ * Desktop IDE Automator — platform-aware process detection + auto-paste
3
+ *
4
+ * Supports macOS (AppleScript/osascript) and Windows (PowerShell/SendKeys).
5
+ * Each IDE has its own keystroke recipe.
6
+ * Falls back gracefully if automation fails.
7
+ */
8
+
9
+ import { execSync } from 'child_process';
10
+ import { log } from './logger.js';
11
+
12
+ const IS_MAC = process.platform === 'darwin';
13
+ const IS_WIN = process.platform === 'win32';
14
+
15
+ // ──────────────────────────────────────────────
16
+ // Per-IDE Automation Recipes
17
+ // ──────────────────────────────────────────────
18
+
19
+ /**
20
+ * Each recipe defines:
21
+ * processName: { mac, win } — binary/process name to detect
22
+ * appName: { mac, win } — display name for activation
23
+ * preKeys: keystroke sequence BEFORE paste (e.g., open Composer in Cursor)
24
+ * preDelay: ms to wait after preKeys
25
+ * postKeys: keystroke sequence AFTER paste (usually Enter to send)
26
+ * pasteDelay: ms to wait between paste and postKeys
27
+ */
28
+ const IDE_RECIPES = {
29
+ 'codex-desktop': {
30
+ processName: { mac: 'Codex', win: 'Codex' },
31
+ appName: { mac: 'Codex', win: 'Codex' },
32
+ // Codex Desktop: input is focused on activate → paste → Enter
33
+ preKeys: null,
34
+ preDelay: 0,
35
+ postKeys: 'return',
36
+ pasteDelay: 300,
37
+ },
38
+
39
+ 'cursor': {
40
+ processName: { mac: 'Cursor', win: 'Cursor' },
41
+ appName: { mac: 'Cursor', win: 'Cursor' },
42
+ // Cursor: Cmd/Ctrl+I opens Composer, then paste, then Enter
43
+ preKeys: IS_MAC
44
+ ? 'keystroke "i" using {command down}'
45
+ : '^i',
46
+ preDelay: 800,
47
+ postKeys: 'return',
48
+ pasteDelay: 300,
49
+ },
50
+
51
+ 'antigravity': {
52
+ processName: { mac: 'Antigravity', win: 'Antigravity' },
53
+ appName: { mac: 'Antigravity', win: 'Antigravity' },
54
+ // Antigravity: chat input auto-focused → paste → Enter
55
+ preKeys: null,
56
+ preDelay: 0,
57
+ postKeys: 'return',
58
+ pasteDelay: 300,
59
+ },
60
+ };
61
+
62
+ // ──────────────────────────────────────────────
63
+ // Process Detection
64
+ // ──────────────────────────────────────────────
65
+
66
+ /**
67
+ * Check if a process is running
68
+ * @param {string} processName - Name of the process
69
+ * @returns {boolean}
70
+ */
71
+ export function isProcessRunning(processName) {
72
+ try {
73
+ if (IS_MAC) {
74
+ const result = execSync(
75
+ `pgrep -x "${processName}" || pgrep -fi "${processName}"`,
76
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
77
+ ).trim();
78
+ return result.length > 0;
79
+ } else if (IS_WIN) {
80
+ const result = execSync(
81
+ `tasklist /FI "IMAGENAME eq ${processName}.exe" /NH`,
82
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
83
+ ).trim();
84
+ return !result.includes('No tasks');
85
+ }
86
+ return false;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ // ──────────────────────────────────────────────
93
+ // macOS Automation (AppleScript)
94
+ // ──────────────────────────────────────────────
95
+
96
+ function buildAppleScript(recipe) {
97
+ const appName = recipe.appName.mac;
98
+ const lines = [];
99
+
100
+ // Activate the application
101
+ lines.push(`tell application "${appName}" to activate`);
102
+ lines.push('delay 0.5');
103
+
104
+ // System Events block for keystrokes
105
+ lines.push('tell application "System Events"');
106
+
107
+ // Pre-keys (e.g., Cmd+I for Cursor Composer)
108
+ if (recipe.preKeys) {
109
+ lines.push(` ${recipe.preKeys}`);
110
+ lines.push(` delay ${recipe.preDelay / 1000}`);
111
+ }
112
+
113
+ // Paste: Cmd+V
114
+ lines.push(' keystroke "v" using {command down}');
115
+ lines.push(` delay ${recipe.pasteDelay / 1000}`);
116
+
117
+ // Post-keys: usually Enter to send
118
+ if (recipe.postKeys === 'return') {
119
+ lines.push(' keystroke return');
120
+ }
121
+
122
+ lines.push('end tell');
123
+
124
+ return lines.join('\n');
125
+ }
126
+
127
+ function runAppleScript(script) {
128
+ try {
129
+ execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, {
130
+ encoding: 'utf-8',
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ timeout: 10000,
133
+ });
134
+ return true;
135
+ } catch (err) {
136
+ log.warn(`AppleScript failed: ${err.message}`);
137
+ if (err.message.includes('not allowed assistive access')) {
138
+ log.error('⚠ Accessibility permission required:');
139
+ log.error(' System Settings → Privacy & Security → Accessibility');
140
+ log.error(' Add your terminal app (Terminal / iTerm2 / Warp)');
141
+ }
142
+ return false;
143
+ }
144
+ }
145
+
146
+ // ──────────────────────────────────────────────
147
+ // Windows Automation (PowerShell)
148
+ // ──────────────────────────────────────────────
149
+
150
+ function buildPowerShell(recipe) {
151
+ const appName = recipe.appName.win;
152
+ const lines = [];
153
+
154
+ lines.push('Add-Type -AssemblyName System.Windows.Forms');
155
+ lines.push('Add-Type -AssemblyName Microsoft.VisualBasic');
156
+
157
+ // Activate window
158
+ lines.push(`[Microsoft.VisualBasic.Interaction]::AppActivate("${appName}")`);
159
+ lines.push('Start-Sleep -Milliseconds 500');
160
+
161
+ // Pre-keys
162
+ if (recipe.preKeys) {
163
+ lines.push(`[System.Windows.Forms.SendKeys]::SendWait("${recipe.preKeys}")`);
164
+ lines.push(`Start-Sleep -Milliseconds ${recipe.preDelay}`);
165
+ }
166
+
167
+ // Paste: Ctrl+V
168
+ lines.push('[System.Windows.Forms.SendKeys]::SendWait("^v")');
169
+ lines.push(`Start-Sleep -Milliseconds ${recipe.pasteDelay}`);
170
+
171
+ // Post-keys: Enter
172
+ if (recipe.postKeys === 'return') {
173
+ lines.push('[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")');
174
+ }
175
+
176
+ return lines.join('; ');
177
+ }
178
+
179
+ function runPowerShell(script) {
180
+ try {
181
+ execSync(`powershell -NoProfile -Command "${script.replace(/"/g, '\\"')}"`, {
182
+ encoding: 'utf-8',
183
+ stdio: ['pipe', 'pipe', 'pipe'],
184
+ timeout: 10000,
185
+ });
186
+ return true;
187
+ } catch (err) {
188
+ log.warn(`PowerShell automation failed: ${err.message}`);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ // ──────────────────────────────────────────────
194
+ // Public API
195
+ // ──────────────────────────────────────────────
196
+
197
+ /**
198
+ * Check if a specific IDE has a known automation recipe
199
+ */
200
+ export function hasRecipe(providerId) {
201
+ return providerId in IDE_RECIPES;
202
+ }
203
+
204
+ /**
205
+ * Check if the IDE is currently running
206
+ */
207
+ export function isIDERunning(providerId) {
208
+ const recipe = IDE_RECIPES[providerId];
209
+ if (!recipe) return false;
210
+
211
+ const processName = IS_MAC ? recipe.processName.mac : recipe.processName.win;
212
+ return isProcessRunning(processName);
213
+ }
214
+
215
+ /**
216
+ * Attempt to auto-paste prompt into the IDE and trigger send
217
+ *
218
+ * Flow: copy to clipboard → activate window → pre-keys → paste → post-keys
219
+ *
220
+ * @param {string} providerId - IDE provider ID
221
+ * @param {string} text - Prompt text to paste
222
+ * @returns {boolean} true if automation succeeded
223
+ */
224
+ export function activateAndPaste(providerId, text) {
225
+ const recipe = IDE_RECIPES[providerId];
226
+ if (!recipe) {
227
+ log.warn(`No automation recipe for '${providerId}'`);
228
+ return false;
229
+ }
230
+
231
+ // Step 1: Check process is running
232
+ const processName = IS_MAC ? recipe.processName.mac : recipe.processName.win;
233
+ if (!isProcessRunning(processName)) {
234
+ log.warn(`${recipe.appName[IS_MAC ? 'mac' : 'win']} is not running`);
235
+ return false;
236
+ }
237
+
238
+ // Step 2: Copy to clipboard (handled by caller, but ensure it's done)
239
+ copyToSystemClipboard(text);
240
+
241
+ // Step 3: Run platform-specific automation
242
+ log.info(`Auto-pasting into ${recipe.appName[IS_MAC ? 'mac' : 'win']}...`);
243
+
244
+ if (IS_MAC) {
245
+ const script = buildAppleScript(recipe);
246
+ return runAppleScript(script);
247
+ } else if (IS_WIN) {
248
+ const script = buildPowerShell(recipe);
249
+ return runPowerShell(script);
250
+ } else {
251
+ log.warn('Desktop automation not supported on this platform');
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Copy text to system clipboard (cross-platform)
258
+ */
259
+ function copyToSystemClipboard(text) {
260
+ try {
261
+ if (IS_MAC) {
262
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
263
+ } else if (IS_WIN) {
264
+ execSync('clip', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
265
+ } else {
266
+ try {
267
+ execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
268
+ } catch {
269
+ execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
270
+ }
271
+ }
272
+ } catch {
273
+ // Clipboard failure is non-fatal — user can copy file manually
274
+ }
275
+ }
276
+
277
+ export const automator = {
278
+ isProcessRunning, hasRecipe, isIDERunning, activateAndPaste,
279
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Checkpoint state manager - Fine-grained state persistence
3
+ *
4
+ * Provides atomic save/load for checkpoint state, enabling
5
+ * resume from any sub-step within a task after interruption.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
9
+ import { resolve } from 'path';
10
+
11
+ /**
12
+ * Create a checkpoint manager for a project
13
+ * @param {string} projectDir - Project root directory
14
+ */
15
+ export function createCheckpoint(projectDir) {
16
+ const statePath = resolve(projectDir, '.codex-copilot/state.json');
17
+ const tempPath = resolve(projectDir, '.codex-copilot/state.json.tmp');
18
+
19
+ /**
20
+ * Load current state from disk
21
+ */
22
+ function load() {
23
+ try {
24
+ return JSON.parse(readFileSync(statePath, 'utf-8'));
25
+ } catch {
26
+ return getDefaultState();
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Save state atomically (write to temp file, then rename)
32
+ * @param {object} update - Partial state to merge
33
+ */
34
+ function save(update) {
35
+ const current = load();
36
+ const merged = {
37
+ ...current,
38
+ ...update,
39
+ last_updated: new Date().toISOString(),
40
+ };
41
+ // Atomic write: temp file + rename prevents corruption on crash
42
+ writeFileSync(tempPath, JSON.stringify(merged, null, 2));
43
+ renameSync(tempPath, statePath);
44
+ return merged;
45
+ }
46
+
47
+ /**
48
+ * Mark a specific phase & step as reached
49
+ */
50
+ function saveStep(taskId, phase, phaseStep, extra = {}) {
51
+ return save({
52
+ current_task: taskId,
53
+ phase,
54
+ phase_step: phaseStep,
55
+ ...extra,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Mark current task as fully completed, clear phase info
61
+ */
62
+ function completeTask(taskId) {
63
+ return save({
64
+ current_task: taskId,
65
+ phase: null,
66
+ phase_step: null,
67
+ current_pr: null,
68
+ review_round: 0,
69
+ branch: null,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Check if a specific phase+step has been reached for a task
75
+ * Returns true if the saved state is at or past the given step
76
+ */
77
+ function isStepDone(taskId, phase, phaseStep) {
78
+ const state = load();
79
+ if (state.current_task !== taskId) return false;
80
+ if (!state.phase) return false;
81
+
82
+ const phaseOrder = ['develop', 'pr', 'review', 'merge'];
83
+ const savedPhaseIdx = phaseOrder.indexOf(state.phase);
84
+ const targetPhaseIdx = phaseOrder.indexOf(phase);
85
+
86
+ // If saved phase is ahead, this step is done
87
+ if (savedPhaseIdx > targetPhaseIdx) return true;
88
+ // If saved phase is behind, this step is not done
89
+ if (savedPhaseIdx < targetPhaseIdx) return false;
90
+
91
+ // Same phase: compare steps within phase
92
+ const stepOrders = {
93
+ develop: ['branch_created', 'prompt_ready', 'codex_complete'],
94
+ pr: ['committed', 'pushed', 'pr_created'],
95
+ review: ['waiting_review', 'feedback_received', 'fix_applied'],
96
+ merge: ['merged'],
97
+ };
98
+
99
+ const steps = stepOrders[phase] || [];
100
+ const savedStepIdx = steps.indexOf(state.phase_step);
101
+ const targetStepIdx = steps.indexOf(phaseStep);
102
+
103
+ return savedStepIdx >= targetStepIdx;
104
+ }
105
+
106
+ /**
107
+ * Full reset to initial state
108
+ */
109
+ function reset() {
110
+ const state = getDefaultState();
111
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
112
+ return state;
113
+ }
114
+
115
+ return { load, save, saveStep, completeTask, isStepDone, reset };
116
+ }
117
+
118
+ function getDefaultState() {
119
+ return {
120
+ current_task: 0,
121
+ phase: null,
122
+ phase_step: null,
123
+ current_pr: null,
124
+ review_round: 0,
125
+ branch: null,
126
+ status: 'initialized',
127
+ last_updated: new Date().toISOString(),
128
+ };
129
+ }
package/src/utils/git.js CHANGED
@@ -91,11 +91,15 @@ function shellEscape(str) {
91
91
  }
92
92
 
93
93
  /**
94
- * Push branch to remote
94
+ * Push branch to remote (handles new branches without upstream)
95
95
  */
96
96
  export function pushBranch(cwd, branch) {
97
97
  validateBranch(branch);
98
- exec(`git push origin ${branch} --force-with-lease`, cwd);
98
+ const result = execSafe(`git push origin ${branch} --force-with-lease`, cwd);
99
+ if (!result.ok) {
100
+ // Force-with-lease fails on new branches — fall back to regular push with upstream
101
+ exec(`git push -u origin ${branch}`, cwd);
102
+ }
99
103
  }
100
104
 
101
105
  /**
@@ -9,6 +9,14 @@ function gh(cmd, cwd) {
9
9
  return execSync(`gh ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
10
10
  }
11
11
 
12
+ function ghSafe(cmd, cwd) {
13
+ try {
14
+ return { ok: true, output: gh(cmd, cwd) };
15
+ } catch (err) {
16
+ return { ok: false, output: err.stderr || err.message };
17
+ }
18
+ }
19
+
12
20
  function ghJSON(cmd, cwd) {
13
21
  const output = gh(cmd, cwd);
14
22
  if (!output) return null;
@@ -19,6 +27,14 @@ function ghJSON(cmd, cwd) {
19
27
  }
20
28
  }
21
29
 
30
+ function ghJSONSafe(cmd, cwd) {
31
+ try {
32
+ return ghJSON(cmd, cwd);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
22
38
  function shellEscape(str) {
23
39
  return `'${str.replace(/'/g, "'\\''")}'`
24
40
  }
@@ -36,9 +52,74 @@ export function checkGhAuth() {
36
52
  }
37
53
 
38
54
  /**
39
- * Create a pull request
55
+ * Ensure the base branch exists on remote.
56
+ * If not, push it first.
57
+ */
58
+ export function ensureRemoteBranch(cwd, branch) {
59
+ try {
60
+ const result = execSync(
61
+ `git ls-remote --heads origin ${branch}`,
62
+ { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
63
+ ).trim();
64
+ if (!result) {
65
+ // Branch doesn't exist on remote, push it
66
+ log.info(`Base branch '${branch}' not found on remote, pushing...`);
67
+ execSync(`git push origin ${branch}`, {
68
+ cwd,
69
+ encoding: 'utf-8',
70
+ stdio: ['pipe', 'pipe', 'pipe'],
71
+ });
72
+ log.info(`Base branch '${branch}' pushed to remote`);
73
+ }
74
+ return true;
75
+ } catch (err) {
76
+ log.warn(`Failed to verify remote branch '${branch}': ${err.message}`);
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if there are commits between base and head branches
83
+ */
84
+ export function hasCommitsBetween(cwd, base, head) {
85
+ try {
86
+ const result = execSync(
87
+ `git log ${base}..${head} --oneline`,
88
+ { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
89
+ ).trim();
90
+ return result.length > 0;
91
+ } catch {
92
+ // If comparison fails (e.g., branches diverged), assume there are commits
93
+ return true;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Find an existing PR for a given head branch
99
+ * @returns {{ number: number, url: string } | null}
100
+ */
101
+ export function findExistingPR(cwd, head) {
102
+ try {
103
+ const prs = ghJSON(
104
+ `pr list --head ${shellEscape(head)} --json number,url --limit 1`,
105
+ cwd
106
+ );
107
+ if (prs && prs.length > 0) {
108
+ return { number: prs[0].number, url: prs[0].url || '' };
109
+ }
110
+ return null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Create a pull request with pre-checks and auto-recovery
40
118
  */
41
119
  export function createPR(cwd, { title, body, base = 'main', head }) {
120
+ // Pre-check: ensure base branch exists on remote
121
+ ensureRemoteBranch(cwd, base);
122
+
42
123
  const output = gh(
43
124
  `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
44
125
  cwd
@@ -56,6 +137,59 @@ export function createPR(cwd, { title, body, base = 'main', head }) {
56
137
  throw new Error(`Failed to create PR: ${output}`);
57
138
  }
58
139
 
140
+ /**
141
+ * Create PR with full auto-recovery: pre-checks → create → fallback to find existing
142
+ * @returns {{ number: number, url: string }}
143
+ */
144
+ export function createPRWithRecovery(cwd, { title, body, base = 'main', head }) {
145
+ // Step 1: Try to create the PR
146
+ try {
147
+ return createPR(cwd, { title, body, base, head });
148
+ } catch (err) {
149
+ log.warn(`PR creation failed: ${err.message}`);
150
+ }
151
+
152
+ // Step 2: Auto-find existing PR for this branch
153
+ log.info('Searching for existing PR...');
154
+ const existing = findExistingPR(cwd, head);
155
+ if (existing) {
156
+ log.info(`Found existing PR #${existing.number}`);
157
+ return existing;
158
+ }
159
+
160
+ // Step 3: Check if there are actually commits to create a PR for
161
+ if (!hasCommitsBetween(cwd, base, head)) {
162
+ log.warn('No commits between base and head branch');
163
+ log.info('This may be a new repo or empty branch. Attempting to push base branch and retry...');
164
+
165
+ // Ensure both branches are pushed
166
+ ensureRemoteBranch(cwd, base);
167
+ ensureRemoteBranch(cwd, head);
168
+
169
+ // Retry PR creation
170
+ try {
171
+ return createPR(cwd, { title, body, base, head });
172
+ } catch (retryErr) {
173
+ log.warn(`PR retry also failed: ${retryErr.message}`);
174
+ }
175
+
176
+ // Last resort: try to find PR again
177
+ const retryExisting = findExistingPR(cwd, head);
178
+ if (retryExisting) {
179
+ log.info(`Found existing PR #${retryExisting.number}`);
180
+ return retryExisting;
181
+ }
182
+ }
183
+
184
+ // Step 4: If all else fails, throw with actionable message
185
+ throw new Error(
186
+ `Cannot create or find PR for branch '${head}'. ` +
187
+ `Possible causes: base branch '${base}' may not exist on remote, ` +
188
+ `or there are no commits between '${base}' and '${head}'. ` +
189
+ `Try: git push origin ${base} && git push origin ${head}`
190
+ );
191
+ }
192
+
59
193
  /**
60
194
  * Get PR review list
61
195
  */
@@ -158,6 +292,8 @@ export function collectReviewFeedback(cwd, prNumber) {
158
292
  }
159
293
 
160
294
  export const github = {
161
- checkGhAuth, createPR, getReviews, getReviewComments,
162
- getIssueComments, getLatestReviewState, mergePR, collectReviewFeedback,
295
+ checkGhAuth, createPR, createPRWithRecovery, findExistingPR,
296
+ ensureRemoteBranch, hasCommitsBetween,
297
+ getReviews, getReviewComments, getIssueComments,
298
+ getLatestReviewState, mergePR, collectReviewFeedback,
163
299
  };