@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/lib/watch.js ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Live terminal UI for Push CLI.
3
+ *
4
+ * Displays real-time daemon status and task execution progress.
5
+ */
6
+
7
+ import { readFileSync, existsSync } from 'fs';
8
+ import { homedir } from 'os';
9
+ import { join } from 'path';
10
+ import readline from 'readline';
11
+ import { codes, colorsEnabled } from './utils/colors.js';
12
+ import { formatDuration, truncate } from './utils/format.js';
13
+
14
+ const STATUS_FILE = join(homedir(), '.push', 'daemon_status.json');
15
+ const REFRESH_INTERVAL = 500; // ms
16
+
17
+ /**
18
+ * Read the current daemon status from file.
19
+ *
20
+ * @returns {Object|null} Status object or null if unavailable
21
+ */
22
+ function readStatus() {
23
+ if (!existsSync(STATUS_FILE)) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const content = readFileSync(STATUS_FILE, 'utf8');
29
+ return JSON.parse(content);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Format a task status line for display.
37
+ *
38
+ * @param {Object} task - Task status object
39
+ * @returns {string} Formatted status line
40
+ */
41
+ function formatTaskLine(task) {
42
+ const num = `#${task.displayNumber || '?'}`.padEnd(5);
43
+ const summary = truncate(task.summary || 'Unknown', 40);
44
+
45
+ let statusIcon = '○';
46
+ let statusColor = codes.yellow;
47
+
48
+ switch (task.status) {
49
+ case 'running':
50
+ statusIcon = '●';
51
+ statusColor = codes.green;
52
+ break;
53
+ case 'completed':
54
+ statusIcon = '✓';
55
+ statusColor = codes.green;
56
+ break;
57
+ case 'failed':
58
+ statusIcon = '✗';
59
+ statusColor = codes.red;
60
+ break;
61
+ case 'queued':
62
+ statusIcon = '○';
63
+ statusColor = codes.yellow;
64
+ break;
65
+ }
66
+
67
+ if (colorsEnabled()) {
68
+ return ` ${statusColor}${statusIcon}${codes.reset} ${num} ${summary}`;
69
+ }
70
+ return ` ${statusIcon} ${num} ${summary}`;
71
+ }
72
+
73
+ /**
74
+ * Format the full UI screen.
75
+ *
76
+ * @param {Object|null} status - Daemon status object
77
+ * @returns {string} Formatted screen content
78
+ */
79
+ function formatUI(status) {
80
+ const lines = [];
81
+
82
+ // Header
83
+ lines.push('');
84
+ if (colorsEnabled()) {
85
+ lines.push(`${codes.bold}Push Daemon Monitor${codes.reset}`);
86
+ } else {
87
+ lines.push('Push Daemon Monitor');
88
+ }
89
+ lines.push('─'.repeat(50));
90
+
91
+ if (!status) {
92
+ lines.push('');
93
+ lines.push(' Daemon not running or status unavailable.');
94
+ lines.push(' Run "push-todo" to start the daemon.');
95
+ lines.push('');
96
+ lines.push('─'.repeat(50));
97
+ lines.push('Press q to quit');
98
+ return lines.join('\n');
99
+ }
100
+
101
+ // Daemon info
102
+ lines.push('');
103
+ const daemonStatus = status.running ? 'Running' : 'Stopped';
104
+ const daemonColor = status.running ? codes.green : codes.red;
105
+
106
+ if (colorsEnabled()) {
107
+ lines.push(` Status: ${daemonColor}${daemonStatus}${codes.reset}`);
108
+ } else {
109
+ lines.push(` Status: ${daemonStatus}`);
110
+ }
111
+
112
+ if (status.pid) {
113
+ lines.push(` PID: ${status.pid}`);
114
+ }
115
+
116
+ if (status.uptime) {
117
+ lines.push(` Uptime: ${formatDuration(status.uptime)}`);
118
+ }
119
+
120
+ // Running tasks
121
+ lines.push('');
122
+ lines.push('─'.repeat(50));
123
+
124
+ const runningTasks = status.runningTasks || [];
125
+ if (runningTasks.length > 0) {
126
+ if (colorsEnabled()) {
127
+ lines.push(`${codes.bold}Running Tasks (${runningTasks.length})${codes.reset}`);
128
+ } else {
129
+ lines.push(`Running Tasks (${runningTasks.length})`);
130
+ }
131
+ lines.push('');
132
+
133
+ for (const task of runningTasks) {
134
+ lines.push(formatTaskLine(task));
135
+
136
+ // Show progress if available
137
+ if (task.progress) {
138
+ const progressBar = renderProgressBar(task.progress, 30);
139
+ lines.push(` ${progressBar}`);
140
+ }
141
+
142
+ // Show current step if available
143
+ if (task.currentStep) {
144
+ const step = truncate(task.currentStep, 40);
145
+ if (colorsEnabled()) {
146
+ lines.push(` ${codes.dim}${step}${codes.reset}`);
147
+ } else {
148
+ lines.push(` ${step}`);
149
+ }
150
+ }
151
+ }
152
+ } else {
153
+ if (colorsEnabled()) {
154
+ lines.push(`${codes.dim}No tasks currently running${codes.reset}`);
155
+ } else {
156
+ lines.push('No tasks currently running');
157
+ }
158
+ }
159
+
160
+ // Queued tasks
161
+ const queuedTasks = status.queuedTasks || [];
162
+ if (queuedTasks.length > 0) {
163
+ lines.push('');
164
+ lines.push('─'.repeat(50));
165
+ if (colorsEnabled()) {
166
+ lines.push(`${codes.bold}Queued (${queuedTasks.length})${codes.reset}`);
167
+ } else {
168
+ lines.push(`Queued (${queuedTasks.length})`);
169
+ }
170
+ lines.push('');
171
+
172
+ for (const task of queuedTasks.slice(0, 5)) {
173
+ lines.push(formatTaskLine(task));
174
+ }
175
+
176
+ if (queuedTasks.length > 5) {
177
+ lines.push(` ... and ${queuedTasks.length - 5} more`);
178
+ }
179
+ }
180
+
181
+ // Completed today
182
+ const completedToday = status.completedToday || [];
183
+ if (completedToday.length > 0) {
184
+ lines.push('');
185
+ lines.push('─'.repeat(50));
186
+ if (colorsEnabled()) {
187
+ lines.push(`${codes.bold}Completed Today (${completedToday.length})${codes.reset}`);
188
+ } else {
189
+ lines.push(`Completed Today (${completedToday.length})`);
190
+ }
191
+ lines.push('');
192
+
193
+ for (const task of completedToday.slice(-3)) {
194
+ lines.push(formatTaskLine({ ...task, status: 'completed' }));
195
+ }
196
+
197
+ if (completedToday.length > 3) {
198
+ lines.push(` ... and ${completedToday.length - 3} more`);
199
+ }
200
+ }
201
+
202
+ // Footer
203
+ lines.push('');
204
+ lines.push('─'.repeat(50));
205
+ lines.push('Press q to quit, r to refresh');
206
+
207
+ return lines.join('\n');
208
+ }
209
+
210
+ /**
211
+ * Render a progress bar.
212
+ *
213
+ * @param {number} progress - Progress 0-100
214
+ * @param {number} width - Bar width in characters
215
+ * @returns {string} Rendered progress bar
216
+ */
217
+ function renderProgressBar(progress, width) {
218
+ const filled = Math.round((progress / 100) * width);
219
+ const empty = width - filled;
220
+
221
+ if (colorsEnabled()) {
222
+ return `${codes.green}${'█'.repeat(filled)}${codes.dim}${'░'.repeat(empty)}${codes.reset} ${progress}%`;
223
+ }
224
+ return `[${'#'.repeat(filled)}${'-'.repeat(empty)}] ${progress}%`;
225
+ }
226
+
227
+ /**
228
+ * Output status as JSON (for non-TTY).
229
+ */
230
+ function outputJSON() {
231
+ const status = readStatus();
232
+ console.log(JSON.stringify(status, null, 2));
233
+ }
234
+
235
+ /**
236
+ * Output status as plain text (for non-TTY).
237
+ */
238
+ function outputPlainText() {
239
+ const status = readStatus();
240
+
241
+ if (!status) {
242
+ console.log('Daemon not running');
243
+ return;
244
+ }
245
+
246
+ console.log(`Status: ${status.running ? 'Running' : 'Stopped'}`);
247
+
248
+ if (status.pid) {
249
+ console.log(`PID: ${status.pid}`);
250
+ }
251
+
252
+ const running = status.runningTasks || [];
253
+ console.log(`Running tasks: ${running.length}`);
254
+
255
+ for (const task of running) {
256
+ console.log(` #${task.displayNumber}: ${task.summary || 'Unknown'}`);
257
+ }
258
+
259
+ const queued = status.queuedTasks || [];
260
+ console.log(`Queued tasks: ${queued.length}`);
261
+ }
262
+
263
+ /**
264
+ * Start the live watch UI.
265
+ *
266
+ * @param {Object} options - Watch options
267
+ * @param {boolean} options.json - Output JSON instead of TUI
268
+ */
269
+ export function startWatch(options = {}) {
270
+ // JSON mode
271
+ if (options.json) {
272
+ outputJSON();
273
+ return;
274
+ }
275
+
276
+ // Non-TTY mode
277
+ if (!process.stdout.isTTY) {
278
+ outputPlainText();
279
+ return;
280
+ }
281
+
282
+ // Live TUI mode
283
+ let running = true;
284
+
285
+ // Hide cursor
286
+ process.stdout.write(codes.hideCursor);
287
+
288
+ // Render function
289
+ function render() {
290
+ const status = readStatus();
291
+ const output = formatUI(status);
292
+ process.stdout.write(codes.clearScreen + codes.cursorHome + output);
293
+ }
294
+
295
+ // Initial render
296
+ render();
297
+
298
+ // Set up refresh interval
299
+ const interval = setInterval(() => {
300
+ if (running) {
301
+ render();
302
+ }
303
+ }, REFRESH_INTERVAL);
304
+
305
+ // Set up keyboard handling
306
+ readline.emitKeypressEvents(process.stdin);
307
+
308
+ if (process.stdin.isTTY) {
309
+ process.stdin.setRawMode(true);
310
+ }
311
+
312
+ // Cleanup function
313
+ function cleanup() {
314
+ running = false;
315
+ clearInterval(interval);
316
+
317
+ // Show cursor
318
+ process.stdout.write(codes.showCursor);
319
+
320
+ // Clear screen and show exit message
321
+ process.stdout.write(codes.clearScreen + codes.cursorHome);
322
+ console.log('Watch mode ended.');
323
+
324
+ if (process.stdin.isTTY) {
325
+ process.stdin.setRawMode(false);
326
+ }
327
+
328
+ process.exit(0);
329
+ }
330
+
331
+ // Handle keyboard input
332
+ process.stdin.on('keypress', (str, key) => {
333
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
334
+ cleanup();
335
+ } else if (key.name === 'r') {
336
+ render();
337
+ }
338
+ });
339
+
340
+ // Handle process signals
341
+ process.on('SIGINT', cleanup);
342
+ process.on('SIGTERM', cleanup);
343
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env swift
2
+ /**
3
+ * Push Keychain Helper
4
+ *
5
+ * Retrieves the E2EE encryption key from iCloud Keychain.
6
+ * Used by the push-todo CLI for decrypting end-to-end encrypted tasks.
7
+ *
8
+ * Exit codes:
9
+ * 0 - Success (key printed to stdout as base64)
10
+ * 1 - Key not found in Keychain
11
+ * 2 - iCloud Keychain not available
12
+ * 3 - Other error
13
+ *
14
+ * Build:
15
+ * swiftc -O KeychainHelper.swift -o push-keychain-helper
16
+ */
17
+
18
+ import Foundation
19
+ import Security
20
+
21
+ /// Keychain service and account identifiers
22
+ /// Must match the iOS app's keychain storage
23
+ let keychainService = "ai.massless.push.e2ee"
24
+ let keychainAccount = "encryption-key"
25
+
26
+ /// Query the keychain for the E2EE encryption key
27
+ func getEncryptionKey() -> (Data?, OSStatus) {
28
+ let query: [String: Any] = [
29
+ kSecClass as String: kSecClassGenericPassword,
30
+ kSecAttrService as String: keychainService,
31
+ kSecAttrAccount as String: keychainAccount,
32
+ kSecAttrSynchronizable as String: kCFBooleanTrue!, // iCloud Keychain
33
+ kSecReturnData as String: kCFBooleanTrue!,
34
+ kSecMatchLimit as String: kSecMatchLimitOne
35
+ ]
36
+
37
+ var result: AnyObject?
38
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
39
+
40
+ if status == errSecSuccess, let data = result as? Data {
41
+ return (data, status)
42
+ }
43
+
44
+ return (nil, status)
45
+ }
46
+
47
+ /// Check if iCloud Keychain is available
48
+ func isICloudKeychainAvailable() -> Bool {
49
+ // Try to query for any iCloud synced item
50
+ let query: [String: Any] = [
51
+ kSecClass as String: kSecClassGenericPassword,
52
+ kSecAttrSynchronizable as String: kCFBooleanTrue!,
53
+ kSecMatchLimit as String: kSecMatchLimitOne,
54
+ kSecReturnAttributes as String: kCFBooleanTrue!
55
+ ]
56
+
57
+ var result: AnyObject?
58
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
59
+
60
+ // If we get errSecItemNotFound, iCloud Keychain is available but empty
61
+ // If we get errSecSuccess, iCloud Keychain is available with items
62
+ // Other errors may indicate iCloud Keychain is not available
63
+ return status == errSecSuccess || status == errSecItemNotFound
64
+ }
65
+
66
+ /// Main entry point
67
+ func main() -> Int32 {
68
+ // Handle --check flag
69
+ if CommandLine.arguments.contains("--check") {
70
+ if isICloudKeychainAvailable() {
71
+ print("iCloud Keychain available")
72
+ return 0
73
+ } else {
74
+ fputs("iCloud Keychain not available\n", stderr)
75
+ return 2
76
+ }
77
+ }
78
+
79
+ // Handle --help flag
80
+ if CommandLine.arguments.contains("--help") || CommandLine.arguments.contains("-h") {
81
+ print("""
82
+ Usage: push-keychain-helper [options]
83
+
84
+ Options:
85
+ --check Check if iCloud Keychain is available
86
+ --help Show this help
87
+
88
+ Without options, retrieves and prints the E2EE key as base64.
89
+
90
+ Exit codes:
91
+ 0 - Success
92
+ 1 - Key not found
93
+ 2 - iCloud Keychain not available
94
+ 3 - Other error
95
+ """)
96
+ return 0
97
+ }
98
+
99
+ // Check iCloud Keychain availability first
100
+ if !isICloudKeychainAvailable() {
101
+ fputs("Error: iCloud Keychain not available\n", stderr)
102
+ return 2
103
+ }
104
+
105
+ // Get the encryption key
106
+ let (keyData, status) = getEncryptionKey()
107
+
108
+ switch status {
109
+ case errSecSuccess:
110
+ if let data = keyData {
111
+ // Output as base64
112
+ print(data.base64EncodedString())
113
+ return 0
114
+ } else {
115
+ fputs("Error: Key data is nil\n", stderr)
116
+ return 3
117
+ }
118
+
119
+ case errSecItemNotFound:
120
+ fputs("Error: Encryption key not found in Keychain\n", stderr)
121
+ fputs("Make sure E2EE is enabled in the Push iOS app\n", stderr)
122
+ return 1
123
+
124
+ case errSecAuthFailed:
125
+ fputs("Error: Keychain authentication failed\n", stderr)
126
+ return 3
127
+
128
+ default:
129
+ fputs("Error: Keychain error (status: \(status))\n", stderr)
130
+ return 3
131
+ }
132
+ }
133
+
134
+ exit(main())
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@masslessai/push-todo",
3
+ "version": "3.0.0",
4
+ "description": "Voice tasks from Push iOS app for Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "push-todo": "./bin/push-todo.js"
8
+ },
9
+ "main": "./lib/index.js",
10
+ "exports": {
11
+ ".": "./lib/index.js",
12
+ "./fetch": "./lib/fetch.js",
13
+ "./connect": "./lib/connect.js",
14
+ "./config": "./lib/config.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "hooks/",
20
+ "commands/",
21
+ "natives/",
22
+ "scripts/",
23
+ ".claude-plugin/",
24
+ "SKILL.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "scripts": {
31
+ "postinstall": "node scripts/postinstall.js",
32
+ "test": "node --test test/",
33
+ "start": "node bin/push-todo.js"
34
+ },
35
+ "keywords": [
36
+ "claude-code",
37
+ "claude-code-plugin",
38
+ "push",
39
+ "voice",
40
+ "todo",
41
+ "ios",
42
+ "cli"
43
+ ],
44
+ "author": "MasslessAI",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/MasslessAI/push-todo-cli"
49
+ },
50
+ "homepage": "https://pushto.do",
51
+ "bugs": {
52
+ "url": "https://github.com/MasslessAI/push-todo-cli/issues"
53
+ }
54
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install script for Push CLI.
4
+ *
5
+ * Downloads the native keychain helper binary for macOS.
6
+ */
7
+
8
+ import { createWriteStream, existsSync, mkdirSync, unlinkSync, readFileSync } from 'fs';
9
+ import { chmod, stat } from 'fs/promises';
10
+ import { pipeline } from 'stream/promises';
11
+ import { join, dirname } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { homedir, platform, arch } from 'os';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ const BINARY_NAME = 'push-keychain-helper';
19
+ const BINARY_DIR = join(__dirname, '../bin');
20
+ const BINARY_PATH = join(BINARY_DIR, BINARY_NAME);
21
+
22
+ // GitHub release URL pattern
23
+ const RELEASE_VERSION = '3.0.0';
24
+ const BINARY_URL = `https://github.com/MasslessAI/push-todo-cli/releases/download/v${RELEASE_VERSION}/${BINARY_NAME}-darwin-arm64`;
25
+ const BINARY_URL_X64 = `https://github.com/MasslessAI/push-todo-cli/releases/download/v${RELEASE_VERSION}/${BINARY_NAME}-darwin-x64`;
26
+
27
+ /**
28
+ * Migrate from old Python installation if present.
29
+ */
30
+ function migrateFromPython() {
31
+ const oldPath = join(homedir(), '.claude', 'skills', 'push-todo');
32
+
33
+ if (existsSync(oldPath)) {
34
+ console.log('[push-todo] Detected previous Python installation.');
35
+ console.log(`[push-todo] Config preserved at ~/.config/push/`);
36
+ console.log(`[push-todo] Old files can be removed: rm -rf ${oldPath}`);
37
+ console.log('');
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Download a file from URL to destination.
43
+ *
44
+ * @param {string} url - Source URL
45
+ * @param {string} dest - Destination path
46
+ * @returns {Promise<boolean>} True if successful
47
+ */
48
+ async function downloadFile(url, dest) {
49
+ try {
50
+ const response = await fetch(url, {
51
+ signal: AbortSignal.timeout(60000) // 60 second timeout
52
+ });
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
56
+ }
57
+
58
+ // Ensure directory exists
59
+ mkdirSync(dirname(dest), { recursive: true });
60
+
61
+ // Remove existing file if present
62
+ if (existsSync(dest)) {
63
+ unlinkSync(dest);
64
+ }
65
+
66
+ // Write to file
67
+ const fileStream = createWriteStream(dest);
68
+ await pipeline(response.body, fileStream);
69
+
70
+ // Make executable
71
+ await chmod(dest, 0o755);
72
+
73
+ return true;
74
+ } catch (error) {
75
+ console.error(`[push-todo] Download failed: ${error.message}`);
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Main post-install routine.
82
+ */
83
+ async function main() {
84
+ console.log('[push-todo] Running post-install...');
85
+
86
+ // Check for migration
87
+ migrateFromPython();
88
+
89
+ // Check platform
90
+ if (platform() !== 'darwin') {
91
+ console.log('[push-todo] Skipping native binary (macOS only)');
92
+ console.log('[push-todo] E2EE features will not be available.');
93
+ console.log('[push-todo] Installation complete.');
94
+ return;
95
+ }
96
+
97
+ // Check if binary already exists and is valid
98
+ if (existsSync(BINARY_PATH)) {
99
+ try {
100
+ const stats = await stat(BINARY_PATH);
101
+ if (stats.size > 0) {
102
+ console.log('[push-todo] Native binary already installed.');
103
+ console.log('[push-todo] Installation complete.');
104
+ return;
105
+ }
106
+ } catch {
107
+ // Continue to download
108
+ }
109
+ }
110
+
111
+ // Determine architecture
112
+ const archType = arch();
113
+ const url = archType === 'arm64' ? BINARY_URL : BINARY_URL_X64;
114
+
115
+ console.log(`[push-todo] Downloading native binary for ${archType}...`);
116
+
117
+ const success = await downloadFile(url, BINARY_PATH);
118
+
119
+ if (success) {
120
+ console.log('[push-todo] Native binary installed successfully.');
121
+ console.log('[push-todo] E2EE decryption is now available.');
122
+ } else {
123
+ console.log('[push-todo] Native binary download failed.');
124
+ console.log('[push-todo] E2EE features will not be available.');
125
+ console.log('[push-todo] You can manually download from:');
126
+ console.log(`[push-todo] ${url}`);
127
+ }
128
+
129
+ console.log('[push-todo] Installation complete.');
130
+ }
131
+
132
+ main().catch(error => {
133
+ console.error(`[push-todo] Post-install error: ${error.message}`);
134
+ // Don't fail the install - E2EE is optional
135
+ process.exit(0);
136
+ });