@masslessai/push-todo 3.0.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/.claude-plugin/plugin.json +5 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/SKILL.md +180 -0
- package/bin/push-todo.js +23 -0
- package/commands/push-todo.md +78 -0
- package/hooks/hooks.json +26 -0
- package/hooks/session-end.js +99 -0
- package/hooks/session-start.js +134 -0
- package/lib/api.js +325 -0
- package/lib/cli.js +191 -0
- package/lib/config.js +279 -0
- package/lib/connect.js +380 -0
- package/lib/encryption.js +201 -0
- package/lib/fetch.js +371 -0
- package/lib/index.js +114 -0
- package/lib/machine-id.js +101 -0
- package/lib/project-registry.js +279 -0
- package/lib/utils/colors.js +126 -0
- package/lib/utils/format.js +253 -0
- package/lib/utils/git.js +149 -0
- package/lib/watch.js +343 -0
- package/natives/KeychainHelper.swift +134 -0
- package/package.json +54 -0
- package/scripts/postinstall.js +136 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting utilities for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Formats tasks and other data for display.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { bold, dim, green, yellow, red, cyan, muted, symbols } from './colors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format a duration in seconds to human-readable string.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} seconds
|
|
13
|
+
* @returns {string} e.g., "2h 30m", "5m", "45s"
|
|
14
|
+
*/
|
|
15
|
+
export function formatDuration(seconds) {
|
|
16
|
+
if (seconds < 60) {
|
|
17
|
+
return `${seconds}s`;
|
|
18
|
+
} else if (seconds < 3600) {
|
|
19
|
+
const mins = Math.floor(seconds / 60);
|
|
20
|
+
const secs = seconds % 60;
|
|
21
|
+
return secs ? `${mins}m ${secs}s` : `${mins}m`;
|
|
22
|
+
} else {
|
|
23
|
+
const hours = Math.floor(seconds / 3600);
|
|
24
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
25
|
+
return mins ? `${hours}h ${mins}m` : `${hours}h`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format a date for display.
|
|
31
|
+
*
|
|
32
|
+
* @param {string|Date} date - ISO date string or Date object
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function formatDate(date) {
|
|
36
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
37
|
+
return d.toLocaleString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format a task for human-readable display.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} task - Task object
|
|
44
|
+
* @returns {string} Formatted task string
|
|
45
|
+
*/
|
|
46
|
+
export function formatTaskForDisplay(task) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
// Build task header with display number and status indicator
|
|
50
|
+
const displayNum = task.displayNumber || task.display_number;
|
|
51
|
+
|
|
52
|
+
// Determine status prefix
|
|
53
|
+
let statusPrefix = '';
|
|
54
|
+
if (task.isCompleted || task.is_completed) {
|
|
55
|
+
statusPrefix = '✅ '; // Completed
|
|
56
|
+
} else if (task.isBacklog || task.is_backlog) {
|
|
57
|
+
statusPrefix = '📦 '; // Backlog
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const numPrefix = displayNum ? `#${displayNum} ` : '';
|
|
61
|
+
const summary = task.summary || 'No summary';
|
|
62
|
+
|
|
63
|
+
lines.push(`## Task: ${numPrefix}${statusPrefix}${summary}`);
|
|
64
|
+
lines.push('');
|
|
65
|
+
|
|
66
|
+
// Project hint
|
|
67
|
+
if (task.projectHint || task.project_hint) {
|
|
68
|
+
lines.push(`**Project:** ${task.projectHint || task.project_hint}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Content
|
|
73
|
+
lines.push('### Content');
|
|
74
|
+
lines.push(task.content || task.normalizedContent || 'No content');
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
// Attachments
|
|
78
|
+
const screenshots = task.screenshotAttachments || task.screenshot_attachments || [];
|
|
79
|
+
const links = task.linkAttachments || task.link_attachments || [];
|
|
80
|
+
|
|
81
|
+
if (screenshots.length > 0 || links.length > 0) {
|
|
82
|
+
lines.push('### Attachments');
|
|
83
|
+
lines.push('');
|
|
84
|
+
|
|
85
|
+
if (screenshots.length > 0) {
|
|
86
|
+
lines.push(`#### Screenshots (${screenshots.length})`);
|
|
87
|
+
screenshots.forEach((screenshot, idx) => {
|
|
88
|
+
const filename = screenshot.imageFilename || 'unknown';
|
|
89
|
+
const width = screenshot.width;
|
|
90
|
+
const height = screenshot.height;
|
|
91
|
+
const dimensions = width && height ? `(${width}x${height})` : '';
|
|
92
|
+
lines.push(`${idx + 1}. ${filename} ${dimensions}`);
|
|
93
|
+
});
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (links.length > 0) {
|
|
98
|
+
lines.push(`#### Links (${links.length})`);
|
|
99
|
+
links.forEach(link => {
|
|
100
|
+
const url = link.url || '';
|
|
101
|
+
const title = link.title || url;
|
|
102
|
+
lines.push(`🔗 [${title}](${url})`);
|
|
103
|
+
});
|
|
104
|
+
lines.push('');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Transcript
|
|
109
|
+
const transcript = task.transcript || task.originalTranscript;
|
|
110
|
+
if (transcript) {
|
|
111
|
+
lines.push('### Original Voice Transcript');
|
|
112
|
+
lines.push(`> ${transcript}`);
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Metadata
|
|
117
|
+
lines.push(`**Task ID:** \`${task.id || 'unknown'}\``);
|
|
118
|
+
if (displayNum) {
|
|
119
|
+
lines.push(`**Display Number:** #${displayNum}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Status
|
|
123
|
+
if (task.isCompleted || task.is_completed) {
|
|
124
|
+
lines.push('**Status:** ✅ Completed');
|
|
125
|
+
// Show session resume hint if session_id is available
|
|
126
|
+
const sessionId = task.executionSessionId || task.execution_session_id;
|
|
127
|
+
if (sessionId) {
|
|
128
|
+
lines.push(`**Session:** Available (\`push-todo resume ${displayNum}\`)`);
|
|
129
|
+
}
|
|
130
|
+
} else if (task.isBacklog || task.is_backlog) {
|
|
131
|
+
lines.push('**Status:** 📦 Backlog');
|
|
132
|
+
} else {
|
|
133
|
+
lines.push('**Status:** Active');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const createdAt = task.createdAt || task.created_at;
|
|
137
|
+
lines.push(`**Created:** ${createdAt || 'unknown'}`);
|
|
138
|
+
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Format a search result for display.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} result - Search result object
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function formatSearchResult(result) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
|
|
151
|
+
const displayNum = result.displayNumber;
|
|
152
|
+
const isCompleted = result.isCompleted;
|
|
153
|
+
const isBacklog = result.isBacklog;
|
|
154
|
+
|
|
155
|
+
// Status indicators
|
|
156
|
+
let status = '';
|
|
157
|
+
if (isCompleted) {
|
|
158
|
+
status = ' [COMPLETED]';
|
|
159
|
+
} else if (isBacklog) {
|
|
160
|
+
status = ' [BACKLOG]';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const numPrefix = displayNum ? `#${displayNum}` : '??';
|
|
164
|
+
const title = result.summary || result.title || 'No summary';
|
|
165
|
+
|
|
166
|
+
lines.push(`**${numPrefix}**${status} ${title}`);
|
|
167
|
+
|
|
168
|
+
// Show match context if available
|
|
169
|
+
const matchContext = result.matchContext;
|
|
170
|
+
if (matchContext) {
|
|
171
|
+
lines.push(` > ${matchContext}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format a task list as a table.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object[]} tasks - Array of tasks
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
export function formatTaskTable(tasks) {
|
|
184
|
+
if (tasks.length === 0) {
|
|
185
|
+
return 'No tasks found.';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const lines = [];
|
|
189
|
+
lines.push('| # | Task | Status |');
|
|
190
|
+
lines.push('|-----|-------------------------------|-----------|');
|
|
191
|
+
|
|
192
|
+
for (const task of tasks) {
|
|
193
|
+
const num = String(task.displayNumber || task.display_number || '?').padEnd(3);
|
|
194
|
+
let summary = (task.summary || 'No summary').slice(0, 28);
|
|
195
|
+
if (summary.length < 28) {
|
|
196
|
+
summary = summary.padEnd(28);
|
|
197
|
+
} else {
|
|
198
|
+
summary = summary.slice(0, 27) + '…';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let status = 'Active';
|
|
202
|
+
if (task.isCompleted || task.is_completed) {
|
|
203
|
+
status = '✅ Done';
|
|
204
|
+
} else if (task.isBacklog || task.is_backlog) {
|
|
205
|
+
status = '📦 Later';
|
|
206
|
+
}
|
|
207
|
+
status = status.padEnd(9);
|
|
208
|
+
|
|
209
|
+
lines.push(`| ${num} | ${summary} | ${status} |`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return lines.join('\n');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Format batch offer for display.
|
|
217
|
+
*
|
|
218
|
+
* @param {Object[]} tasks - Tasks to offer
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
export function formatBatchOffer(tasks) {
|
|
222
|
+
const count = tasks.length;
|
|
223
|
+
const numbers = tasks.map(t => t.displayNumber || t.display_number).join(',');
|
|
224
|
+
|
|
225
|
+
const lines = [];
|
|
226
|
+
lines.push('='.repeat(50));
|
|
227
|
+
lines.push(`BATCH_OFFER: ${count}`);
|
|
228
|
+
lines.push(`BATCH_TASKS: ${numbers}`);
|
|
229
|
+
|
|
230
|
+
for (const task of tasks) {
|
|
231
|
+
const num = task.displayNumber || task.display_number;
|
|
232
|
+
const summary = (task.summary || 'No summary').slice(0, 50);
|
|
233
|
+
lines.push(` #${num} - ${summary}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lines.push('='.repeat(50));
|
|
237
|
+
|
|
238
|
+
return lines.join('\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Truncate a string to a maximum length.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} str
|
|
245
|
+
* @param {number} maxLength
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function truncate(str, maxLength) {
|
|
249
|
+
if (!str || str.length <= maxLength) {
|
|
250
|
+
return str || '';
|
|
251
|
+
}
|
|
252
|
+
return str.slice(0, maxLength - 1) + '…';
|
|
253
|
+
}
|
package/lib/utils/git.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utilities for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for git operations like getting remote URLs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the normalized git remote URL for the current directory.
|
|
11
|
+
*
|
|
12
|
+
* Normalizes URLs to a consistent format:
|
|
13
|
+
* - git@github.com:user/repo.git → github.com/user/repo
|
|
14
|
+
* - https://github.com/user/repo.git → github.com/user/repo
|
|
15
|
+
*
|
|
16
|
+
* @returns {string|null} Normalized git remote or null if not a git repo
|
|
17
|
+
*/
|
|
18
|
+
export function getGitRemote() {
|
|
19
|
+
try {
|
|
20
|
+
const result = execSync('git remote get-url origin', {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
timeout: 5000,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let url = result.trim();
|
|
27
|
+
if (!url) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Normalize: remove protocol, convert : to /, remove .git
|
|
32
|
+
// git@github.com:user/repo.git → github.com/user/repo
|
|
33
|
+
// https://github.com/user/repo.git → github.com/user/repo
|
|
34
|
+
|
|
35
|
+
// Remove protocol prefixes
|
|
36
|
+
const prefixes = ['https://', 'http://', 'git@', 'ssh://git@'];
|
|
37
|
+
for (const prefix of prefixes) {
|
|
38
|
+
if (url.startsWith(prefix)) {
|
|
39
|
+
url = url.slice(prefix.length);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Convert : to / (for git@ style)
|
|
45
|
+
if (url.includes(':') && !url.includes('://')) {
|
|
46
|
+
url = url.replace(':', '/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Remove .git suffix
|
|
50
|
+
if (url.endsWith('.git')) {
|
|
51
|
+
url = url.slice(0, -4);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return url;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if the current directory is a git repository.
|
|
62
|
+
*
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isGitRepo() {
|
|
66
|
+
try {
|
|
67
|
+
execSync('git rev-parse --git-dir', {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
timeout: 5000,
|
|
70
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the current git branch name.
|
|
80
|
+
*
|
|
81
|
+
* @returns {string|null}
|
|
82
|
+
*/
|
|
83
|
+
export function getCurrentBranch() {
|
|
84
|
+
try {
|
|
85
|
+
const result = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
89
|
+
});
|
|
90
|
+
return result.trim() || null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the root directory of the git repository.
|
|
98
|
+
*
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
export function getGitRoot() {
|
|
102
|
+
try {
|
|
103
|
+
const result = execSync('git rev-parse --show-toplevel', {
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
timeout: 5000,
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
107
|
+
});
|
|
108
|
+
return result.trim() || null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get recent commit messages for context.
|
|
116
|
+
*
|
|
117
|
+
* @param {number} count - Number of commits to fetch
|
|
118
|
+
* @returns {string[]} Array of commit messages
|
|
119
|
+
*/
|
|
120
|
+
export function getRecentCommits(count = 5) {
|
|
121
|
+
try {
|
|
122
|
+
const result = execSync(`git log --oneline -${count}`, {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: 5000,
|
|
125
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
126
|
+
});
|
|
127
|
+
return result.trim().split('\n').filter(Boolean);
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if there are uncommitted changes.
|
|
135
|
+
*
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
export function hasUncommittedChanges() {
|
|
139
|
+
try {
|
|
140
|
+
const result = execSync('git status --porcelain', {
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
timeout: 5000,
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
144
|
+
});
|
|
145
|
+
return result.trim().length > 0;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|