@masslessai/push-todo 4.0.9 → 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.
@@ -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
- // Try to find the worktree relative to CWD's parent (sibling directory)
355
- const candidate = join(dirname(process.cwd()), worktreeName);
356
- if (existsSync(candidate)) {
357
- resumeCwd = candidate;
358
- console.log(`Found daemon worktree: ${candidate}`);
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(candidate, { recursive: true });
365
- resumeCwd = candidate;
366
- console.log(`Re-created worktree directory for session lookup: ${candidate}`);
372
+ mkdirSync(newCandidate, { recursive: true });
373
+ resumeCwd = newCandidate;
374
+ console.log(`Re-created worktree directory for session lookup: ${newCandidate}`);
367
375
  } catch {
368
- console.log(dim(`Could not create worktree dir at ${candidate}, using current directory`));
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
  };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.0.9",
3
+ "version": "4.1.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {