@masslessai/push-todo 4.0.7 → 4.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.
- package/lib/auto-connect.js +195 -0
- package/lib/cli.js +28 -9
- package/lib/connect.js +64 -1
- package/lib/daemon.js +152 -74
- package/lib/discovery.js +334 -0
- package/lib/utils/git.js +26 -1
- package/lib/utils/spinner.js +73 -0
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-connect orchestrator for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Scans the user's home folder for Claude Code, Codex, and OpenClaw projects,
|
|
5
|
+
* then batch-registers them all with the Push backend in one shot.
|
|
6
|
+
*
|
|
7
|
+
* Usage: push-todo connect --auto
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getApiKey, saveCredentials, getEmail } from './config.js';
|
|
11
|
+
import {
|
|
12
|
+
CLIENT_TO_ACTION_TYPE,
|
|
13
|
+
registerProjectWithBackendExplicit,
|
|
14
|
+
registerProjectLocally,
|
|
15
|
+
doFullDeviceAuth,
|
|
16
|
+
} from './connect.js';
|
|
17
|
+
import { getRegistry } from './project-registry.js';
|
|
18
|
+
import { normalizeGitRemote } from './utils/git.js';
|
|
19
|
+
import { discoverAllProjects } from './discovery.js';
|
|
20
|
+
import { createSpinner } from './utils/spinner.js';
|
|
21
|
+
import { bold, green, red, dim, cyan } from './utils/colors.js';
|
|
22
|
+
import { ensureDaemonRunning } from './daemon-health.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the auto-connect flow.
|
|
26
|
+
*
|
|
27
|
+
* 1. Ensure authenticated
|
|
28
|
+
* 2. Scan for projects across all agents
|
|
29
|
+
* 3. Deduplicate and filter already-registered
|
|
30
|
+
* 4. Batch-register with backend + local registry
|
|
31
|
+
* 5. Display progress and summary
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} options - CLI options
|
|
34
|
+
*/
|
|
35
|
+
export async function runAutoConnect(options = {}) {
|
|
36
|
+
// Self-healing: ensure daemon is running
|
|
37
|
+
ensureDaemonRunning();
|
|
38
|
+
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(` ${bold('Push Auto-Connect')}`);
|
|
41
|
+
console.log(` ${'='.repeat(40)}`);
|
|
42
|
+
console.log('');
|
|
43
|
+
|
|
44
|
+
// Phase 1: Ensure authenticated
|
|
45
|
+
let apiKey;
|
|
46
|
+
try {
|
|
47
|
+
apiKey = getApiKey();
|
|
48
|
+
} catch {
|
|
49
|
+
apiKey = null;
|
|
50
|
+
}
|
|
51
|
+
const email = getEmail();
|
|
52
|
+
|
|
53
|
+
if (!apiKey || !email) {
|
|
54
|
+
console.log(' Not authenticated. Starting auth flow...');
|
|
55
|
+
console.log('');
|
|
56
|
+
const authResult = await doFullDeviceAuth('claude-code');
|
|
57
|
+
saveCredentials(authResult.api_key, authResult.email);
|
|
58
|
+
apiKey = authResult.api_key;
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log(` ${green('✓')} Authenticated as ${authResult.email}`);
|
|
61
|
+
console.log('');
|
|
62
|
+
} else {
|
|
63
|
+
console.log(` ${green('✓')} Authenticated as ${email}`);
|
|
64
|
+
console.log('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Phase 2: Scan for projects
|
|
68
|
+
console.log(' Scanning for projects...');
|
|
69
|
+
|
|
70
|
+
const spinner = createSpinner();
|
|
71
|
+
|
|
72
|
+
spinner.start('Scanning Claude Code projects...');
|
|
73
|
+
const { projects, counts } = discoverAllProjects();
|
|
74
|
+
|
|
75
|
+
// Show per-agent results
|
|
76
|
+
if (counts.claudeCode > 0) {
|
|
77
|
+
spinner.succeed(`Claude Code: ${counts.claudeCode} projects found`);
|
|
78
|
+
} else {
|
|
79
|
+
spinner.info('Claude Code: no projects found');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spinner2 = createSpinner();
|
|
83
|
+
if (counts.codex > 0) {
|
|
84
|
+
spinner2.succeed(`Codex: ${counts.codex} projects found`);
|
|
85
|
+
} else {
|
|
86
|
+
spinner2.info('Codex: not installed or no projects');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const spinner3 = createSpinner();
|
|
90
|
+
if (counts.openclaw > 0) {
|
|
91
|
+
spinner3.succeed(`OpenClaw: ${counts.openclaw} projects found`);
|
|
92
|
+
} else {
|
|
93
|
+
spinner3.info('OpenClaw: not installed or no projects');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (projects.length === 0) {
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(' No projects with git remotes found.');
|
|
99
|
+
console.log(` ${dim('Projects must have a git remote to be registered.')}`);
|
|
100
|
+
console.log('');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(` ${bold(`${projects.length} unique projects`)} to process`);
|
|
106
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
107
|
+
console.log('');
|
|
108
|
+
|
|
109
|
+
// Phase 3: Check which are already registered
|
|
110
|
+
const registry = getRegistry();
|
|
111
|
+
const toRegister = [];
|
|
112
|
+
let alreadyConnected = 0;
|
|
113
|
+
|
|
114
|
+
for (const project of projects) {
|
|
115
|
+
const actionType = CLIENT_TO_ACTION_TYPE[project.agentType] || project.agentType;
|
|
116
|
+
const existingPath = registry.getPathWithoutUpdate(project.gitRemote, actionType);
|
|
117
|
+
if (existingPath) {
|
|
118
|
+
alreadyConnected++;
|
|
119
|
+
console.log(` ${dim('○')} ${dim(project.gitRemote)} ${dim(`(${project.agentType})`)} ${dim('— already connected')}`);
|
|
120
|
+
} else {
|
|
121
|
+
toRegister.push(project);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (toRegister.length === 0) {
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log(` ${'='.repeat(40)}`);
|
|
128
|
+
console.log(` All ${alreadyConnected} projects already connected.`);
|
|
129
|
+
console.log('');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Phase 4: Register new projects
|
|
134
|
+
let registered = 0;
|
|
135
|
+
let failed = 0;
|
|
136
|
+
|
|
137
|
+
for (const project of toRegister) {
|
|
138
|
+
const regSpinner = createSpinner();
|
|
139
|
+
const label = `${project.gitRemote} (${project.agentType})`;
|
|
140
|
+
regSpinner.start(`Registering ${label}...`);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const result = await registerProjectWithBackendExplicit(
|
|
144
|
+
apiKey,
|
|
145
|
+
project.agentType,
|
|
146
|
+
project.projectPath,
|
|
147
|
+
project.gitRemote,
|
|
148
|
+
project.projectContext
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (result.status === 'unauthorized') {
|
|
152
|
+
regSpinner.fail(`${label} — session expired`);
|
|
153
|
+
console.log('');
|
|
154
|
+
console.log(` ${red('Error:')} API key invalid or revoked.`);
|
|
155
|
+
console.log(` Run ${cyan("'push-todo connect --reauth'")} to re-authenticate.`);
|
|
156
|
+
console.log('');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result.status === 'success') {
|
|
161
|
+
// Register locally
|
|
162
|
+
const actionType = result.action_type || CLIENT_TO_ACTION_TYPE[project.agentType] || project.agentType;
|
|
163
|
+
registerProjectLocally(project.gitRemote, project.projectPath, {
|
|
164
|
+
actionType,
|
|
165
|
+
actionId: result.action_id,
|
|
166
|
+
actionName: result.action_name,
|
|
167
|
+
});
|
|
168
|
+
regSpinner.succeed(label);
|
|
169
|
+
registered++;
|
|
170
|
+
} else {
|
|
171
|
+
regSpinner.fail(`${label} — ${result.message || 'unknown error'}`);
|
|
172
|
+
failed++;
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
regSpinner.fail(`${label} — ${error.message}`);
|
|
176
|
+
failed++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Phase 5: Summary
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(` ${'='.repeat(40)}`);
|
|
183
|
+
|
|
184
|
+
const parts = [];
|
|
185
|
+
if (registered > 0) parts.push(green(`${registered} registered`));
|
|
186
|
+
if (alreadyConnected > 0) parts.push(dim(`${alreadyConnected} already connected`));
|
|
187
|
+
if (failed > 0) parts.push(red(`${failed} failed`));
|
|
188
|
+
|
|
189
|
+
console.log(` ${parts.join(', ')}`);
|
|
190
|
+
|
|
191
|
+
if (registered > 0) {
|
|
192
|
+
console.log(` Run ${cyan("'push-todo'")} to see your tasks.`);
|
|
193
|
+
}
|
|
194
|
+
console.log('');
|
|
195
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -95,6 +95,7 @@ ${bold('CONNECT OPTIONS:')}
|
|
|
95
95
|
--validate-project Validate project registration (JSON output)
|
|
96
96
|
--store-e2ee-key <key> Import E2EE encryption key
|
|
97
97
|
--description <text> Project description (with connect)
|
|
98
|
+
--auto Auto-discover and register all projects
|
|
98
99
|
|
|
99
100
|
${bold('CONFIRM (for daemon skills):')}
|
|
100
101
|
push-todo confirm --type "social_post" --title "Post tweet" --content "..."
|
|
@@ -158,6 +159,7 @@ const options = {
|
|
|
158
159
|
'validate-project': { type: 'boolean' },
|
|
159
160
|
'store-e2ee-key': { type: 'string' },
|
|
160
161
|
'description': { type: 'string' },
|
|
162
|
+
'auto': { type: 'boolean' },
|
|
161
163
|
// Confirm command options
|
|
162
164
|
'type': { type: 'string' },
|
|
163
165
|
'title': { type: 'string' },
|
|
@@ -351,21 +353,34 @@ export async function run(argv) {
|
|
|
351
353
|
const suffix = parts.length > 1 ? parts[parts.length - 1].slice(0, 8) : machineId.slice(0, 8);
|
|
352
354
|
const worktreeName = `push-${displayNumber}-${suffix}`;
|
|
353
355
|
|
|
354
|
-
//
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
// Check new location first (inside project at .claude/worktrees/)
|
|
357
|
+
const newCandidate = join(process.cwd(), '.claude', 'worktrees', worktreeName);
|
|
358
|
+
// Check legacy location (sibling directory, pre-migration daemon)
|
|
359
|
+
const legacyCandidate = join(dirname(process.cwd()), worktreeName);
|
|
360
|
+
|
|
361
|
+
if (existsSync(newCandidate)) {
|
|
362
|
+
resumeCwd = newCandidate;
|
|
363
|
+
console.log(`Found daemon worktree: ${newCandidate}`);
|
|
364
|
+
} else if (existsSync(legacyCandidate)) {
|
|
365
|
+
resumeCwd = legacyCandidate;
|
|
366
|
+
console.log(`Found daemon worktree (legacy): ${legacyCandidate}`);
|
|
359
367
|
} else {
|
|
360
368
|
// Worktree was cleaned up after daemon finished, but the session file
|
|
361
369
|
// still exists at ~/.claude/projects/. Re-create the directory so Claude
|
|
362
370
|
// maps CWD to the correct session directory and finds the session.
|
|
363
371
|
try {
|
|
364
|
-
mkdirSync(
|
|
365
|
-
resumeCwd =
|
|
366
|
-
console.log(`Re-created worktree directory for session lookup: ${
|
|
372
|
+
mkdirSync(newCandidate, { recursive: true });
|
|
373
|
+
resumeCwd = newCandidate;
|
|
374
|
+
console.log(`Re-created worktree directory for session lookup: ${newCandidate}`);
|
|
367
375
|
} catch {
|
|
368
|
-
|
|
376
|
+
// Fall back to legacy location
|
|
377
|
+
try {
|
|
378
|
+
mkdirSync(legacyCandidate, { recursive: true });
|
|
379
|
+
resumeCwd = legacyCandidate;
|
|
380
|
+
console.log(`Re-created worktree directory for session lookup: ${legacyCandidate}`);
|
|
381
|
+
} catch {
|
|
382
|
+
console.log(dim(`Could not create worktree dir, using current directory`));
|
|
383
|
+
}
|
|
369
384
|
}
|
|
370
385
|
}
|
|
371
386
|
} catch {
|
|
@@ -633,6 +648,10 @@ export async function run(argv) {
|
|
|
633
648
|
|
|
634
649
|
// Connect command
|
|
635
650
|
if (command === 'connect') {
|
|
651
|
+
if (values.auto) {
|
|
652
|
+
const { runAutoConnect } = await import('./auto-connect.js');
|
|
653
|
+
return runAutoConnect(values);
|
|
654
|
+
}
|
|
636
655
|
return runConnect(values);
|
|
637
656
|
}
|
|
638
657
|
|
package/lib/connect.js
CHANGED
|
@@ -950,7 +950,7 @@ async function registerProjectWithBackend(apiKey, clientType = 'claude-code', ke
|
|
|
950
950
|
|
|
951
951
|
// Mapping from CLI client_type to canonical DB action_type
|
|
952
952
|
// Must match CLIENT_TO_ACTION_TYPE in register-project edge function
|
|
953
|
-
const CLIENT_TO_ACTION_TYPE = {
|
|
953
|
+
export const CLIENT_TO_ACTION_TYPE = {
|
|
954
954
|
'claude-code': 'claude-code',
|
|
955
955
|
'openai-codex': 'openai-codex',
|
|
956
956
|
'openclaw': 'openclaw',
|
|
@@ -1378,6 +1378,65 @@ export async function runConnect(options = {}) {
|
|
|
1378
1378
|
console.log('');
|
|
1379
1379
|
}
|
|
1380
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* Register a project with the backend using explicit path and remote.
|
|
1383
|
+
* Used by auto-connect for registering discovered projects at arbitrary paths.
|
|
1384
|
+
*
|
|
1385
|
+
* @param {string} apiKey - Push API key
|
|
1386
|
+
* @param {string} clientType - Agent type (claude-code, openai-codex, openclaw)
|
|
1387
|
+
* @param {string} projectPath - Absolute path to the project
|
|
1388
|
+
* @param {string} gitRemote - Raw git remote URL
|
|
1389
|
+
* @param {string|null} projectContext - First 50 lines of CLAUDE.md/README.md for keyword generation
|
|
1390
|
+
* @returns {Promise<Object>} Registration result with status, action_id, action_type, etc.
|
|
1391
|
+
*/
|
|
1392
|
+
async function registerProjectWithBackendExplicit(apiKey, clientType, projectPath, gitRemote, projectContext = null) {
|
|
1393
|
+
const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
|
|
1394
|
+
|
|
1395
|
+
const payload = {
|
|
1396
|
+
client_type: clientType,
|
|
1397
|
+
client_name: clientName,
|
|
1398
|
+
device_name: getDeviceName(),
|
|
1399
|
+
project_path: projectPath,
|
|
1400
|
+
git_remote: gitRemote,
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
if (projectContext) {
|
|
1404
|
+
payload.project_context = projectContext;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const response = await fetch(`${API_BASE}/register-project`, {
|
|
1408
|
+
method: 'POST',
|
|
1409
|
+
headers: {
|
|
1410
|
+
'Content-Type': 'application/json',
|
|
1411
|
+
'apikey': ANON_KEY,
|
|
1412
|
+
'Authorization': `Bearer ${apiKey}`
|
|
1413
|
+
},
|
|
1414
|
+
body: JSON.stringify(payload)
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
if (!response.ok) {
|
|
1418
|
+
if (response.status === 401) {
|
|
1419
|
+
return { status: 'unauthorized', message: 'API key invalid or revoked' };
|
|
1420
|
+
}
|
|
1421
|
+
const body = await response.json().catch(() => ({}));
|
|
1422
|
+
return { status: 'error', message: body.error_description || `HTTP ${response.status}` };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const data = await response.json();
|
|
1426
|
+
if (data.success) {
|
|
1427
|
+
return {
|
|
1428
|
+
status: 'success',
|
|
1429
|
+
action_id: data.action_id || null,
|
|
1430
|
+
action_type: data.action_type || null,
|
|
1431
|
+
action_name: data.normalized_name || data.action_name || 'Unknown',
|
|
1432
|
+
created: data.created !== false,
|
|
1433
|
+
message: data.message || ''
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
return { status: 'error', message: 'Unknown error' };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1381
1440
|
export {
|
|
1382
1441
|
checkVersion,
|
|
1383
1442
|
doUpdate,
|
|
@@ -1388,5 +1447,9 @@ export {
|
|
|
1388
1447
|
storeE2EEKeyDirect,
|
|
1389
1448
|
showStatus,
|
|
1390
1449
|
getInstallationMethod,
|
|
1450
|
+
registerProjectWithBackendExplicit,
|
|
1451
|
+
registerProjectLocally,
|
|
1452
|
+
doFullDeviceAuth,
|
|
1453
|
+
CLIENT_NAMES,
|
|
1391
1454
|
VERSION
|
|
1392
1455
|
};
|
package/lib/daemon.js
CHANGED
|
@@ -448,10 +448,23 @@ async function fetchQueuedTasks() {
|
|
|
448
448
|
|
|
449
449
|
async function fetchScheduledTodos() {
|
|
450
450
|
try {
|
|
451
|
+
const machineId = getMachineId();
|
|
451
452
|
const params = new URLSearchParams();
|
|
452
453
|
params.set('scheduled_before', new Date().toISOString());
|
|
454
|
+
if (machineId) {
|
|
455
|
+
params.set('machine_id', machineId);
|
|
456
|
+
}
|
|
453
457
|
|
|
454
|
-
|
|
458
|
+
// Include registered git_remotes so backend can scope to this machine's projects
|
|
459
|
+
const projects = getListedProjects();
|
|
460
|
+
const gitRemotes = Object.keys(projects);
|
|
461
|
+
const headers = {};
|
|
462
|
+
if (machineId && gitRemotes.length > 0) {
|
|
463
|
+
headers['X-Machine-Id'] = machineId;
|
|
464
|
+
headers['X-Git-Remotes'] = gitRemotes.join(',');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const response = await apiRequest(`synced-todos?${params}`, { headers });
|
|
455
468
|
if (!response.ok) return [];
|
|
456
469
|
|
|
457
470
|
const data = await response.json();
|
|
@@ -466,11 +479,33 @@ async function checkAndQueueScheduledTodos() {
|
|
|
466
479
|
const scheduledTodos = await fetchScheduledTodos();
|
|
467
480
|
if (scheduledTodos.length === 0) return;
|
|
468
481
|
|
|
482
|
+
const registeredProjects = getListedProjects();
|
|
483
|
+
|
|
469
484
|
for (const todo of scheduledTodos) {
|
|
470
|
-
const dn = todo.displayNumber;
|
|
485
|
+
const dn = todo.displayNumber || todo.display_number;
|
|
471
486
|
const todoId = todo.id;
|
|
472
487
|
|
|
473
|
-
|
|
488
|
+
// Skip tasks already in an execution state (running, queued, completed, failed, etc.)
|
|
489
|
+
const execStatus = todo.executionStatus || todo.execution_status;
|
|
490
|
+
if (execStatus && execStatus !== 'none') {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Skip completed tasks
|
|
495
|
+
if (todo.isCompleted || todo.is_completed) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Skip tasks whose project is not registered on this machine
|
|
500
|
+
const gitRemote = todo.gitRemote || todo.git_remote;
|
|
501
|
+
if (gitRemote && Object.keys(registeredProjects).length > 0) {
|
|
502
|
+
if (!(gitRemote in registeredProjects)) {
|
|
503
|
+
log(`Schedule skipped #${dn}: project ${gitRemote} not registered on this machine`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
log(`Schedule triggered for #${dn} (reminder_date: ${todo.reminderDate || todo.reminder_date})`);
|
|
474
509
|
|
|
475
510
|
try {
|
|
476
511
|
await updateTaskStatus(dn, 'queued', {
|
|
@@ -557,8 +592,7 @@ async function claimTask(displayNumber, todoId, sessionId) {
|
|
|
557
592
|
return true;
|
|
558
593
|
}
|
|
559
594
|
|
|
560
|
-
const
|
|
561
|
-
const branch = `push-${displayNumber}-${suffix}`;
|
|
595
|
+
const branch = getBranchName(displayNumber);
|
|
562
596
|
|
|
563
597
|
const payload = {
|
|
564
598
|
todoId: todoId || undefined, // UUID lookup (primary, avoids display_number collisions)
|
|
@@ -731,62 +765,32 @@ function getWorktreeSuffix() {
|
|
|
731
765
|
return 'local';
|
|
732
766
|
}
|
|
733
767
|
|
|
734
|
-
|
|
768
|
+
// Worktree name passed to Claude Code's --worktree flag
|
|
769
|
+
function getWorktreeName(displayNumber) {
|
|
735
770
|
const suffix = getWorktreeSuffix();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (projectPath) {
|
|
739
|
-
return join(dirname(projectPath), worktreeName);
|
|
740
|
-
}
|
|
741
|
-
return join(process.cwd(), '..', worktreeName);
|
|
771
|
+
return `push-${displayNumber}-${suffix}`;
|
|
742
772
|
}
|
|
743
773
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (existsSync(worktreePath)) {
|
|
750
|
-
log(`Worktree already exists: ${worktreePath}`);
|
|
751
|
-
return worktreePath;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
const gitCwd = projectPath || process.cwd();
|
|
774
|
+
// Branch name created by Claude Code's --worktree (adds "worktree-" prefix)
|
|
775
|
+
function getBranchName(displayNumber) {
|
|
776
|
+
return `worktree-${getWorktreeName(displayNumber)}`;
|
|
777
|
+
}
|
|
755
778
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
timeout: 30000,
|
|
761
|
-
stdio: 'pipe'
|
|
762
|
-
});
|
|
763
|
-
log(`Created worktree: ${worktreePath}`);
|
|
764
|
-
return worktreePath;
|
|
765
|
-
} catch {
|
|
766
|
-
// Branch might already exist, try without -b
|
|
767
|
-
try {
|
|
768
|
-
execSync(`git worktree add "${worktreePath}" ${branch}`, {
|
|
769
|
-
cwd: gitCwd,
|
|
770
|
-
timeout: 30000,
|
|
771
|
-
stdio: 'pipe'
|
|
772
|
-
});
|
|
773
|
-
log(`Created worktree (existing branch): ${worktreePath}`);
|
|
774
|
-
return worktreePath;
|
|
775
|
-
} catch (e) {
|
|
776
|
-
logError(`Failed to create worktree: ${e.message}`);
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
779
|
+
function getWorktreePath(displayNumber, projectPath) {
|
|
780
|
+
const wtName = getWorktreeName(displayNumber);
|
|
781
|
+
const basePath = projectPath || process.cwd();
|
|
782
|
+
return join(basePath, '.claude', 'worktrees', wtName);
|
|
780
783
|
}
|
|
781
784
|
|
|
785
|
+
// createWorktree() removed — Claude Code's --worktree flag handles creation
|
|
786
|
+
|
|
782
787
|
function cleanupWorktree(displayNumber, projectPath) {
|
|
783
788
|
const worktreePath = getWorktreePath(displayNumber, projectPath);
|
|
784
789
|
|
|
785
790
|
if (!existsSync(worktreePath)) return;
|
|
786
791
|
|
|
787
792
|
const gitCwd = projectPath || process.cwd();
|
|
788
|
-
const
|
|
789
|
-
const branch = `push-${displayNumber}-${suffix}`;
|
|
793
|
+
const branch = getBranchName(displayNumber);
|
|
790
794
|
|
|
791
795
|
try {
|
|
792
796
|
execSync(`git worktree remove "${worktreePath}" --force`, {
|
|
@@ -804,8 +808,7 @@ function cleanupWorktree(displayNumber, projectPath) {
|
|
|
804
808
|
// ==================== PR Auto-Creation ====================
|
|
805
809
|
|
|
806
810
|
function createPRForTask(displayNumber, summary, projectPath) {
|
|
807
|
-
const
|
|
808
|
-
const branch = `push-${displayNumber}-${suffix}`;
|
|
811
|
+
const branch = getBranchName(displayNumber);
|
|
809
812
|
const gitCwd = projectPath || process.cwd();
|
|
810
813
|
|
|
811
814
|
try {
|
|
@@ -889,8 +892,7 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
|
|
|
889
892
|
if (firstResult !== 'conflict') return false; // non-conflict failure
|
|
890
893
|
|
|
891
894
|
// Conflict detected — try to resolve with Claude
|
|
892
|
-
const
|
|
893
|
-
const branch = `push-${displayNumber}-${suffix}`;
|
|
895
|
+
const branch = getBranchName(displayNumber);
|
|
894
896
|
const resolved = resolveConflictsWithClaude(displayNumber, branch, gitCwd);
|
|
895
897
|
if (!resolved) return false;
|
|
896
898
|
|
|
@@ -1103,13 +1105,14 @@ async function hasApprovedConfirmation(displayNumber) {
|
|
|
1103
1105
|
* Returns true if the task was healed (status updated, no re-execution needed).
|
|
1104
1106
|
*/
|
|
1105
1107
|
async function autoHealExistingWork(displayNumber, summary, projectPath, taskId) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
+
const branch = getBranchName(displayNumber);
|
|
1109
|
+
const legacyBranch = `push-${displayNumber}-${getWorktreeSuffix()}`;
|
|
1108
1110
|
const gitCwd = projectPath || process.cwd();
|
|
1109
1111
|
|
|
1110
1112
|
try {
|
|
1111
|
-
// Check if branch has commits ahead of main
|
|
1113
|
+
// Check if branch has commits ahead of main (try new name, then legacy)
|
|
1112
1114
|
let hasCommits = false;
|
|
1115
|
+
let activeBranch = branch;
|
|
1113
1116
|
try {
|
|
1114
1117
|
const logResult = execSync(
|
|
1115
1118
|
`git log HEAD..origin/${branch} --oneline 2>/dev/null || git log HEAD..${branch} --oneline 2>/dev/null`,
|
|
@@ -1117,22 +1120,35 @@ async function autoHealExistingWork(displayNumber, summary, projectPath, taskId)
|
|
|
1117
1120
|
).toString().trim();
|
|
1118
1121
|
hasCommits = logResult.length > 0;
|
|
1119
1122
|
} catch {
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1123
|
+
// New branch doesn't exist — try legacy branch from pre-migration daemon
|
|
1124
|
+
try {
|
|
1125
|
+
const legacyResult = execSync(
|
|
1126
|
+
`git log HEAD..origin/${legacyBranch} --oneline 2>/dev/null || git log HEAD..${legacyBranch} --oneline 2>/dev/null`,
|
|
1127
|
+
{ cwd: gitCwd, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
1128
|
+
).toString().trim();
|
|
1129
|
+
if (legacyResult.length > 0) {
|
|
1130
|
+
hasCommits = true;
|
|
1131
|
+
activeBranch = legacyBranch;
|
|
1132
|
+
log(`Task #${displayNumber}: found work on legacy branch ${legacyBranch}`);
|
|
1133
|
+
}
|
|
1134
|
+
} catch {
|
|
1135
|
+
// Neither branch exists — no previous work
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1122
1138
|
}
|
|
1123
1139
|
|
|
1124
1140
|
if (!hasCommits) {
|
|
1125
1141
|
return false;
|
|
1126
1142
|
}
|
|
1127
1143
|
|
|
1128
|
-
log(`Task #${displayNumber}: found existing commits on branch ${
|
|
1144
|
+
log(`Task #${displayNumber}: found existing commits on branch ${activeBranch}`);
|
|
1129
1145
|
|
|
1130
1146
|
// Check for existing PR
|
|
1131
1147
|
let prUrl = null;
|
|
1132
1148
|
let prState = null;
|
|
1133
1149
|
try {
|
|
1134
1150
|
const prResult = execSync(
|
|
1135
|
-
`gh pr list --head ${
|
|
1151
|
+
`gh pr list --head ${activeBranch} --state all --json url,state --jq '.[0]' 2>/dev/null`,
|
|
1136
1152
|
{ cwd: gitCwd, timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
1137
1153
|
).toString().trim();
|
|
1138
1154
|
if (prResult) {
|
|
@@ -1391,6 +1407,51 @@ function extractSemanticSummary(worktreePath, sessionId) {
|
|
|
1391
1407
|
}
|
|
1392
1408
|
}
|
|
1393
1409
|
|
|
1410
|
+
/**
|
|
1411
|
+
* Ask Claude to generate a Mermaid diagram of the key change it made.
|
|
1412
|
+
* Uses the same session-resume pattern as extractSemanticSummary.
|
|
1413
|
+
*
|
|
1414
|
+
* @param {string} worktreePath - Path to the git worktree where Claude ran
|
|
1415
|
+
* @param {string} sessionId - Claude session ID
|
|
1416
|
+
* @returns {string|null} Mermaid diagram source code, or null if extraction fails
|
|
1417
|
+
*/
|
|
1418
|
+
function extractVisualArtifact(worktreePath, sessionId) {
|
|
1419
|
+
if (!worktreePath || !sessionId) return null;
|
|
1420
|
+
|
|
1421
|
+
try {
|
|
1422
|
+
const result = execFileSync('claude', [
|
|
1423
|
+
'--resume', sessionId,
|
|
1424
|
+
'--print',
|
|
1425
|
+
'Generate a Mermaid diagram showing the key architectural change or flow you implemented. ' +
|
|
1426
|
+
'Use graph LR for component relationships, sequenceDiagram for API/data flow, ' +
|
|
1427
|
+
'or gitGraph for branch operations. Keep it simple (5-10 nodes max). ' +
|
|
1428
|
+
'Output ONLY the raw Mermaid code. No markdown fences, no explanation, no comments.'
|
|
1429
|
+
], {
|
|
1430
|
+
cwd: worktreePath,
|
|
1431
|
+
timeout: 30000,
|
|
1432
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
const mermaid = result.toString().trim();
|
|
1436
|
+
if (!mermaid || mermaid.length === 0) return null;
|
|
1437
|
+
|
|
1438
|
+
// Validate: must start with a known Mermaid diagram keyword
|
|
1439
|
+
const validStarts = ['graph ', 'graph\n', 'flowchart ', 'flowchart\n',
|
|
1440
|
+
'sequenceDiagram', 'gitGraph', 'classDiagram', 'stateDiagram',
|
|
1441
|
+
'erDiagram', 'gantt', 'pie'];
|
|
1442
|
+
if (!validStarts.some(s => mermaid.startsWith(s))) {
|
|
1443
|
+
log(`Visual artifact extraction returned non-Mermaid content, skipping`);
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Cap at 2000 chars — diagrams beyond this are too complex to be useful
|
|
1448
|
+
return mermaid.length > 2000 ? mermaid.slice(0, 2000) : mermaid;
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
log(`Visual artifact extraction failed: ${error.message}`);
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1394
1455
|
// ==================== Task Execution ====================
|
|
1395
1456
|
|
|
1396
1457
|
function updateTaskDetail(displayNumber, updates) {
|
|
@@ -1479,13 +1540,9 @@ async function executeTask(task) {
|
|
|
1479
1540
|
|
|
1480
1541
|
log(`Executing task #${displayNumber}: ${content.slice(0, 60)}...`);
|
|
1481
1542
|
|
|
1482
|
-
//
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
await updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' }, taskId);
|
|
1486
|
-
taskDetails.delete(displayNumber);
|
|
1487
|
-
return null;
|
|
1488
|
-
}
|
|
1543
|
+
// Worktree path — Claude Code's --worktree flag handles creation
|
|
1544
|
+
const worktreeName = getWorktreeName(displayNumber);
|
|
1545
|
+
const worktreePath = getWorktreePath(displayNumber, projectPath);
|
|
1489
1546
|
|
|
1490
1547
|
taskProjectPaths.set(displayNumber, projectPath);
|
|
1491
1548
|
|
|
@@ -1540,6 +1597,7 @@ async function executeTask(task) {
|
|
|
1540
1597
|
|
|
1541
1598
|
const claudeArgs = [
|
|
1542
1599
|
'-p', prompt,
|
|
1600
|
+
'--worktree', worktreeName,
|
|
1543
1601
|
'--allowedTools', allowedTools,
|
|
1544
1602
|
'--output-format', 'json',
|
|
1545
1603
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -1548,7 +1606,7 @@ async function executeTask(task) {
|
|
|
1548
1606
|
|
|
1549
1607
|
try {
|
|
1550
1608
|
const child = spawn('claude', claudeArgs, {
|
|
1551
|
-
cwd:
|
|
1609
|
+
cwd: projectPath || process.cwd(),
|
|
1552
1610
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1553
1611
|
env: (() => {
|
|
1554
1612
|
const env = { ...process.env, PUSH_TASK_ID: task.id, PUSH_DISPLAY_NUMBER: String(displayNumber) };
|
|
@@ -1675,8 +1733,11 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1675
1733
|
// Auto-create PR first so we can include it in the summary
|
|
1676
1734
|
const prUrl = createPRForTask(displayNumber, summary, projectPath);
|
|
1677
1735
|
|
|
1678
|
-
// Ask Claude to summarize what it accomplished
|
|
1679
|
-
|
|
1736
|
+
// Ask Claude to summarize what it accomplished.
|
|
1737
|
+
// If --worktree auto-removed (no code changes), fall back to project path.
|
|
1738
|
+
const summaryPath = existsSync(worktreePath) ? worktreePath : (projectPath || process.cwd());
|
|
1739
|
+
const semanticSummary = extractSemanticSummary(summaryPath, sessionId);
|
|
1740
|
+
const visualArtifact = extractVisualArtifact(summaryPath, sessionId);
|
|
1680
1741
|
|
|
1681
1742
|
// Clean up worktree BEFORE merge — gh pr merge --delete-branch fails if
|
|
1682
1743
|
// the local branch is still referenced by a worktree. The branch itself
|
|
@@ -1706,6 +1767,22 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1706
1767
|
}, info.taskId);
|
|
1707
1768
|
}
|
|
1708
1769
|
|
|
1770
|
+
// Append visual artifact as a separate timeline event (non-blocking)
|
|
1771
|
+
if (visualArtifact) {
|
|
1772
|
+
log(`Task #${displayNumber}: Sending visual artifact (${visualArtifact.length} chars)`);
|
|
1773
|
+
await updateTaskStatus(displayNumber, 'session_finished', {
|
|
1774
|
+
event: {
|
|
1775
|
+
type: 'visual_artifact',
|
|
1776
|
+
timestamp: new Date().toISOString(),
|
|
1777
|
+
machineName: machineName || undefined,
|
|
1778
|
+
format: 'mermaid',
|
|
1779
|
+
content: visualArtifact
|
|
1780
|
+
}
|
|
1781
|
+
}, info.taskId).catch(err => {
|
|
1782
|
+
log(`Task #${displayNumber}: Visual artifact upload failed (non-fatal): ${err.message}`);
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1709
1786
|
if (NOTIFY_ON_COMPLETE) {
|
|
1710
1787
|
const prNote = prUrl ? ' PR ready for review.' : '';
|
|
1711
1788
|
sendMacNotification(
|
|
@@ -1725,8 +1802,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1725
1802
|
// check if a previous PR for this branch was already merged.
|
|
1726
1803
|
// See: docs/20260211_auto_complete_failure_investigation.md (Fix D)
|
|
1727
1804
|
if (!prUrl && !merged) {
|
|
1728
|
-
const
|
|
1729
|
-
const branch = `push-${displayNumber}-${suffix}`;
|
|
1805
|
+
const branch = getBranchName(displayNumber);
|
|
1730
1806
|
try {
|
|
1731
1807
|
const prCheck = execFileSync('gh', [
|
|
1732
1808
|
'pr', 'list', '--head', branch, '--state', 'merged',
|
|
@@ -1794,8 +1870,10 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1794
1870
|
log(`Task #${displayNumber} stderr: ${stderr.slice(0, 500)}`);
|
|
1795
1871
|
}
|
|
1796
1872
|
|
|
1797
|
-
// Ask Claude to explain what went wrong
|
|
1798
|
-
|
|
1873
|
+
// Ask Claude to explain what went wrong.
|
|
1874
|
+
// If --worktree auto-removed, fall back to project path.
|
|
1875
|
+
const failSummaryPath = existsSync(worktreePath) ? worktreePath : (projectPath || process.cwd());
|
|
1876
|
+
const failureSummary = extractSemanticSummary(failSummaryPath, sessionId);
|
|
1799
1877
|
|
|
1800
1878
|
// Clean up worktree after summary extraction
|
|
1801
1879
|
cleanupWorktree(displayNumber, projectPath);
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Discovery Engine for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Scans the user's home folder for projects used with Claude Code, Codex,
|
|
5
|
+
* and OpenClaw. Returns deduplicated list of (gitRemote, agentType) pairs.
|
|
6
|
+
*
|
|
7
|
+
* Discovery sources:
|
|
8
|
+
* - Claude Code: ~/.claude/projects/ (sessions-index.json or path decode)
|
|
9
|
+
* - Codex: ~/.codex/sessions/ (JSONL first-line cwd extraction)
|
|
10
|
+
* - OpenClaw: ~/.openclaw/agents/main/sessions/ (JSONL first-line cwd)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { getGitRemoteForPath, normalizeGitRemote } from './utils/git.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} DiscoveredProject
|
|
20
|
+
* @property {string} projectPath - Absolute path to the project directory
|
|
21
|
+
* @property {string} gitRemote - Normalized git remote URL
|
|
22
|
+
* @property {string} agentType - 'claude-code' | 'openai-codex' | 'openclaw'
|
|
23
|
+
* @property {string} source - Human-readable description of discovery source
|
|
24
|
+
* @property {string|null} projectContext - First 50 lines of CLAUDE.md or README.md (for keyword generation)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// CLAUDE CODE DISCOVERY
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Discover projects from Claude Code's ~/.claude/projects/ directory.
|
|
33
|
+
*
|
|
34
|
+
* Strategy:
|
|
35
|
+
* 1. List all dirs, skip worktree dirs (contain '--claude-worktrees')
|
|
36
|
+
* 2. For dirs WITH sessions-index.json: extract unique projectPaths
|
|
37
|
+
* 3. For dirs WITHOUT: decode path naively (replace - with /)
|
|
38
|
+
* 4. Verify each path exists and has a git remote
|
|
39
|
+
*
|
|
40
|
+
* @returns {DiscoveredProject[]}
|
|
41
|
+
*/
|
|
42
|
+
export function discoverClaudeCodeProjects() {
|
|
43
|
+
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
44
|
+
if (!existsSync(projectsDir)) return [];
|
|
45
|
+
|
|
46
|
+
const results = [];
|
|
47
|
+
const seenPaths = new Set();
|
|
48
|
+
|
|
49
|
+
let dirs;
|
|
50
|
+
try {
|
|
51
|
+
dirs = readdirSync(projectsDir, { withFileTypes: true })
|
|
52
|
+
.filter(d => d.isDirectory())
|
|
53
|
+
.map(d => d.name);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const dirName of dirs) {
|
|
59
|
+
// Skip worktree dirs
|
|
60
|
+
if (dirName.includes('--claude-worktrees')) continue;
|
|
61
|
+
|
|
62
|
+
const dirPath = join(projectsDir, dirName);
|
|
63
|
+
|
|
64
|
+
// Try sessions-index.json first (most reliable — contains real projectPath)
|
|
65
|
+
const indexPath = join(dirPath, 'sessions-index.json');
|
|
66
|
+
if (existsSync(indexPath)) {
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
69
|
+
if (data.entries && Array.isArray(data.entries)) {
|
|
70
|
+
for (const entry of data.entries) {
|
|
71
|
+
if (entry.projectPath && !seenPaths.has(entry.projectPath)) {
|
|
72
|
+
seenPaths.add(entry.projectPath);
|
|
73
|
+
addIfValid(results, entry.projectPath, 'claude-code', 'Claude Code session');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
continue; // sessions-index.json handled this dir
|
|
78
|
+
} catch {
|
|
79
|
+
// Fall through to naive decode
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: naive path decode
|
|
84
|
+
// -Users-yuxianggu-projects-AppleWhisper → /Users/yuxianggu/projects/AppleWhisper
|
|
85
|
+
// This is lossy for paths containing hyphens, but we verify existence
|
|
86
|
+
if (dirName.startsWith('-')) {
|
|
87
|
+
const decoded = '/' + dirName.slice(1).replace(/-/g, '/');
|
|
88
|
+
if (!seenPaths.has(decoded) && existsSync(decoded)) {
|
|
89
|
+
seenPaths.add(decoded);
|
|
90
|
+
addIfValid(results, decoded, 'claude-code', 'Claude Code project');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// CODEX DISCOVERY
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Discover projects from Codex's ~/.codex/sessions/ directory.
|
|
104
|
+
*
|
|
105
|
+
* Strategy:
|
|
106
|
+
* 1. Recursively find *.jsonl files under ~/.codex/sessions/
|
|
107
|
+
* 2. Read first line, parse JSON, extract payload.cwd
|
|
108
|
+
* 3. Verify each path exists and has a git remote
|
|
109
|
+
*
|
|
110
|
+
* @returns {DiscoveredProject[]}
|
|
111
|
+
*/
|
|
112
|
+
export function discoverCodexProjects() {
|
|
113
|
+
const sessionsDir = join(homedir(), '.codex', 'sessions');
|
|
114
|
+
if (!existsSync(sessionsDir)) return [];
|
|
115
|
+
|
|
116
|
+
const results = [];
|
|
117
|
+
const seenPaths = new Set();
|
|
118
|
+
|
|
119
|
+
const jsonlFiles = findJsonlFiles(sessionsDir);
|
|
120
|
+
|
|
121
|
+
for (const filePath of jsonlFiles) {
|
|
122
|
+
const cwd = extractCwdFromJsonl(filePath, 'codex');
|
|
123
|
+
if (cwd && !seenPaths.has(cwd)) {
|
|
124
|
+
seenPaths.add(cwd);
|
|
125
|
+
addIfValid(results, cwd, 'openai-codex', 'Codex session');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// OPENCLAW DISCOVERY
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Discover projects from OpenClaw's session files.
|
|
138
|
+
*
|
|
139
|
+
* Strategy:
|
|
140
|
+
* 1. Read *.jsonl files from ~/.openclaw/agents/main/sessions/
|
|
141
|
+
* 2. Extract cwd from first line
|
|
142
|
+
* 3. Skip generic workspace (~/.openclaw/workspace) — not a real project
|
|
143
|
+
* 4. Verify each path exists and has a git remote
|
|
144
|
+
*
|
|
145
|
+
* @returns {DiscoveredProject[]}
|
|
146
|
+
*/
|
|
147
|
+
export function discoverOpenClawProjects() {
|
|
148
|
+
const sessionsDir = join(homedir(), '.openclaw', 'agents', 'main', 'sessions');
|
|
149
|
+
if (!existsSync(sessionsDir)) return [];
|
|
150
|
+
|
|
151
|
+
const results = [];
|
|
152
|
+
const seenPaths = new Set();
|
|
153
|
+
const workspacePath = join(homedir(), '.openclaw', 'workspace');
|
|
154
|
+
|
|
155
|
+
let files;
|
|
156
|
+
try {
|
|
157
|
+
files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl') && !f.includes('.deleted.'));
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const fileName of files) {
|
|
163
|
+
const filePath = join(sessionsDir, fileName);
|
|
164
|
+
const cwd = extractCwdFromJsonl(filePath, 'openclaw');
|
|
165
|
+
if (cwd && cwd !== workspacePath && !seenPaths.has(cwd)) {
|
|
166
|
+
seenPaths.add(cwd);
|
|
167
|
+
addIfValid(results, cwd, 'openclaw', 'OpenClaw session');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return results;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// UNIFIED DISCOVERY
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Discover all projects from all supported agents.
|
|
180
|
+
*
|
|
181
|
+
* Runs each agent's scanner and deduplicates results by (gitRemote, agentType).
|
|
182
|
+
*
|
|
183
|
+
* @returns {{ projects: DiscoveredProject[], counts: { claudeCode: number, codex: number, openclaw: number } }}
|
|
184
|
+
*/
|
|
185
|
+
export function discoverAllProjects() {
|
|
186
|
+
const claudeCode = discoverClaudeCodeProjects();
|
|
187
|
+
const codex = discoverCodexProjects();
|
|
188
|
+
const openclaw = discoverOpenClawProjects();
|
|
189
|
+
|
|
190
|
+
const all = [...claudeCode, ...codex, ...openclaw];
|
|
191
|
+
const deduplicated = deduplicateProjects(all);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
projects: deduplicated,
|
|
195
|
+
counts: {
|
|
196
|
+
claudeCode: claudeCode.length,
|
|
197
|
+
codex: codex.length,
|
|
198
|
+
openclaw: openclaw.length,
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deduplicate discovered projects by (gitRemote, agentType) pair.
|
|
205
|
+
*
|
|
206
|
+
* @param {DiscoveredProject[]} projects
|
|
207
|
+
* @returns {DiscoveredProject[]}
|
|
208
|
+
*/
|
|
209
|
+
export function deduplicateProjects(projects) {
|
|
210
|
+
const seen = new Map();
|
|
211
|
+
|
|
212
|
+
for (const project of projects) {
|
|
213
|
+
const key = `${project.gitRemote}::${project.agentType}`;
|
|
214
|
+
if (!seen.has(key)) {
|
|
215
|
+
seen.set(key, project);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Array.from(seen.values());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// HELPERS
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if a path is a valid project with a git remote, and add to results if so.
|
|
228
|
+
*
|
|
229
|
+
* @param {DiscoveredProject[]} results - Array to push to
|
|
230
|
+
* @param {string} projectPath - Absolute path to check
|
|
231
|
+
* @param {string} agentType - Agent type identifier
|
|
232
|
+
* @param {string} source - Human-readable source description
|
|
233
|
+
*/
|
|
234
|
+
function addIfValid(results, projectPath, agentType, source) {
|
|
235
|
+
if (!existsSync(projectPath)) return;
|
|
236
|
+
|
|
237
|
+
// Check it's a directory
|
|
238
|
+
try {
|
|
239
|
+
if (!statSync(projectPath).isDirectory()) return;
|
|
240
|
+
} catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const gitRemote = getGitRemoteForPath(projectPath);
|
|
245
|
+
if (!gitRemote) return;
|
|
246
|
+
|
|
247
|
+
const projectContext = readProjectContext(projectPath);
|
|
248
|
+
results.push({ projectPath, gitRemote, agentType, source, projectContext });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Read project context for keyword generation.
|
|
253
|
+
*
|
|
254
|
+
* Tries CLAUDE.md first (richer, written for AI), then README.md.
|
|
255
|
+
* Returns first 50 lines or null if neither exists.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} projectPath - Absolute path to the project
|
|
258
|
+
* @returns {string|null} First 50 lines of project documentation
|
|
259
|
+
*/
|
|
260
|
+
function readProjectContext(projectPath) {
|
|
261
|
+
for (const filename of ['CLAUDE.md', 'README.md']) {
|
|
262
|
+
const filePath = join(projectPath, filename);
|
|
263
|
+
if (!existsSync(filePath)) continue;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const content = readFileSync(filePath, 'utf8');
|
|
267
|
+
const lines = content.split('\n').slice(0, 50);
|
|
268
|
+
const text = lines.join('\n').trim();
|
|
269
|
+
if (text.length > 0) return text;
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Extract cwd from the first line of a JSONL session file.
|
|
279
|
+
*
|
|
280
|
+
* Handles two formats:
|
|
281
|
+
* - Codex: {"type":"session_meta","payload":{"cwd":"/path",...}}
|
|
282
|
+
* - OpenClaw: {"type":"session","cwd":"/path",...}
|
|
283
|
+
*
|
|
284
|
+
* @param {string} filePath - Absolute path to the JSONL file
|
|
285
|
+
* @param {'codex'|'openclaw'} format - Which format to expect
|
|
286
|
+
* @returns {string|null} The cwd value or null
|
|
287
|
+
*/
|
|
288
|
+
function extractCwdFromJsonl(filePath, format) {
|
|
289
|
+
try {
|
|
290
|
+
const content = readFileSync(filePath, 'utf8');
|
|
291
|
+
const firstLine = content.split('\n')[0];
|
|
292
|
+
if (!firstLine) return null;
|
|
293
|
+
|
|
294
|
+
const data = JSON.parse(firstLine);
|
|
295
|
+
|
|
296
|
+
if (format === 'codex') {
|
|
297
|
+
return data?.payload?.cwd || null;
|
|
298
|
+
}
|
|
299
|
+
// openclaw
|
|
300
|
+
return data?.cwd || null;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Recursively find all .jsonl files in a directory.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} dir - Directory to search
|
|
310
|
+
* @param {number} maxDepth - Maximum recursion depth
|
|
311
|
+
* @returns {string[]}
|
|
312
|
+
*/
|
|
313
|
+
function findJsonlFiles(dir, maxDepth = 5) {
|
|
314
|
+
if (maxDepth <= 0) return [];
|
|
315
|
+
|
|
316
|
+
const results = [];
|
|
317
|
+
let entries;
|
|
318
|
+
try {
|
|
319
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
320
|
+
} catch {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
const fullPath = join(dir, entry.name);
|
|
326
|
+
if (entry.isDirectory()) {
|
|
327
|
+
results.push(...findJsonlFiles(fullPath, maxDepth - 1));
|
|
328
|
+
} else if (entry.name.endsWith('.jsonl') && !entry.name.includes('.deleted.')) {
|
|
329
|
+
results.push(fullPath);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return results;
|
|
334
|
+
}
|
package/lib/utils/git.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Provides helpers for git operations like getting remote URLs.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { execSync } from 'child_process';
|
|
7
|
+
import { execSync, execFileSync } from 'child_process';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Get the normalized git remote URL for the current directory.
|
|
@@ -148,6 +148,31 @@ export function hasUncommittedChanges() {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Get the normalized git remote URL for a specific directory path.
|
|
153
|
+
*
|
|
154
|
+
* Uses execFileSync (no shell) for safe execution with untrusted paths.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} projectPath - Absolute path to the project directory
|
|
157
|
+
* @returns {string|null} Normalized git remote or null if not a git repo / no remote
|
|
158
|
+
*/
|
|
159
|
+
export function getGitRemoteForPath(projectPath) {
|
|
160
|
+
try {
|
|
161
|
+
const result = execFileSync('git', ['-C', projectPath, 'remote', 'get-url', 'origin'], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
timeout: 5000,
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const url = result.trim();
|
|
168
|
+
if (!url) return null;
|
|
169
|
+
|
|
170
|
+
return normalizeGitRemote(url);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
151
176
|
/**
|
|
152
177
|
* Normalize a git remote URL to a consistent format.
|
|
153
178
|
*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple ANSI spinner for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Braille-frame spinner that overwrites a single terminal line.
|
|
5
|
+
* Uses the same ANSI patterns as watch.js (no external dependencies).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { codes } from './colors.js';
|
|
9
|
+
|
|
10
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
11
|
+
const INTERVAL_MS = 80;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a spinner that updates in-place on a single line.
|
|
15
|
+
*
|
|
16
|
+
* @returns {{ start(text: string): void, update(text: string): void, succeed(text: string): void, fail(text: string): void, stop(): void }}
|
|
17
|
+
*/
|
|
18
|
+
export function createSpinner() {
|
|
19
|
+
let frameIndex = 0;
|
|
20
|
+
let intervalId = null;
|
|
21
|
+
let currentText = '';
|
|
22
|
+
|
|
23
|
+
function render() {
|
|
24
|
+
const frame = `${codes.cyan}${FRAMES[frameIndex]}${codes.reset}`;
|
|
25
|
+
process.stdout.write(`\r${codes.clearLine} ${frame} ${currentText}`);
|
|
26
|
+
frameIndex = (frameIndex + 1) % FRAMES.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
start(text) {
|
|
31
|
+
currentText = text;
|
|
32
|
+
frameIndex = 0;
|
|
33
|
+
render();
|
|
34
|
+
intervalId = setInterval(render, INTERVAL_MS);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
update(text) {
|
|
38
|
+
currentText = text;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
succeed(text) {
|
|
42
|
+
if (intervalId) {
|
|
43
|
+
clearInterval(intervalId);
|
|
44
|
+
intervalId = null;
|
|
45
|
+
}
|
|
46
|
+
process.stdout.write(`\r${codes.clearLine} ${codes.green}✓${codes.reset} ${text}\n`);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
fail(text) {
|
|
50
|
+
if (intervalId) {
|
|
51
|
+
clearInterval(intervalId);
|
|
52
|
+
intervalId = null;
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write(`\r${codes.clearLine} ${codes.red}✗${codes.reset} ${text}\n`);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
info(text) {
|
|
58
|
+
if (intervalId) {
|
|
59
|
+
clearInterval(intervalId);
|
|
60
|
+
intervalId = null;
|
|
61
|
+
}
|
|
62
|
+
process.stdout.write(`\r${codes.clearLine} ${codes.dim}○${codes.reset} ${text}\n`);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
stop() {
|
|
66
|
+
if (intervalId) {
|
|
67
|
+
clearInterval(intervalId);
|
|
68
|
+
intervalId = null;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(`\r${codes.clearLine}`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|