@cmdctrl/cursor-ide 0.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.
Files changed (62) hide show
  1. package/dist/adapter/cdp-client.d.ts +66 -0
  2. package/dist/adapter/cdp-client.d.ts.map +1 -0
  3. package/dist/adapter/cdp-client.js +304 -0
  4. package/dist/adapter/cdp-client.js.map +1 -0
  5. package/dist/adapter/cursor-db.d.ts +114 -0
  6. package/dist/adapter/cursor-db.d.ts.map +1 -0
  7. package/dist/adapter/cursor-db.js +438 -0
  8. package/dist/adapter/cursor-db.js.map +1 -0
  9. package/dist/client/messages.d.ts +98 -0
  10. package/dist/client/messages.d.ts.map +1 -0
  11. package/dist/client/messages.js +6 -0
  12. package/dist/client/messages.js.map +1 -0
  13. package/dist/client/websocket.d.ts +103 -0
  14. package/dist/client/websocket.d.ts.map +1 -0
  15. package/dist/client/websocket.js +428 -0
  16. package/dist/client/websocket.js.map +1 -0
  17. package/dist/commands/register.d.ts +10 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +175 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/commands/start.d.ts +9 -0
  22. package/dist/commands/start.d.ts.map +1 -0
  23. package/dist/commands/start.js +86 -0
  24. package/dist/commands/start.js.map +1 -0
  25. package/dist/commands/status.d.ts +5 -0
  26. package/dist/commands/status.d.ts.map +1 -0
  27. package/dist/commands/status.js +75 -0
  28. package/dist/commands/status.js.map +1 -0
  29. package/dist/commands/stop.d.ts +5 -0
  30. package/dist/commands/stop.d.ts.map +1 -0
  31. package/dist/commands/stop.js +59 -0
  32. package/dist/commands/stop.js.map +1 -0
  33. package/dist/config/config.d.ts +68 -0
  34. package/dist/config/config.d.ts.map +1 -0
  35. package/dist/config/config.js +189 -0
  36. package/dist/config/config.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +34 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/session-discovery.d.ts +22 -0
  42. package/dist/session-discovery.d.ts.map +1 -0
  43. package/dist/session-discovery.js +90 -0
  44. package/dist/session-discovery.js.map +1 -0
  45. package/dist/session-watcher.d.ts +62 -0
  46. package/dist/session-watcher.d.ts.map +1 -0
  47. package/dist/session-watcher.js +210 -0
  48. package/dist/session-watcher.js.map +1 -0
  49. package/package.json +40 -0
  50. package/src/adapter/cdp-client.ts +296 -0
  51. package/src/adapter/cursor-db.ts +486 -0
  52. package/src/client/messages.ts +138 -0
  53. package/src/client/websocket.ts +486 -0
  54. package/src/commands/register.ts +201 -0
  55. package/src/commands/start.ts +106 -0
  56. package/src/commands/status.ts +83 -0
  57. package/src/commands/stop.ts +58 -0
  58. package/src/config/config.ts +167 -0
  59. package/src/index.ts +39 -0
  60. package/src/session-discovery.ts +115 -0
  61. package/src/session-watcher.ts +253 -0
  62. package/tsconfig.json +19 -0
@@ -0,0 +1,106 @@
1
+ import {
2
+ readConfig,
3
+ readCredentials,
4
+ isRegistered,
5
+ writePidFile,
6
+ isDaemonRunning,
7
+ cursorDbExists,
8
+ } from '../config/config';
9
+ import { DaemonClient } from '../client/websocket';
10
+ import { getCDPClient } from '../adapter/cdp-client';
11
+
12
+ interface StartOptions {
13
+ foreground?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Start command - launch the daemon and connect to server
18
+ */
19
+ export async function start(options: StartOptions): Promise<void> {
20
+ // Set up global error handlers to catch silent crashes
21
+ process.on('uncaughtException', (err) => {
22
+ console.error('[FATAL] Uncaught exception:', err);
23
+ process.exit(1);
24
+ });
25
+
26
+ process.on('unhandledRejection', (reason, promise) => {
27
+ console.error('[FATAL] Unhandled promise rejection:', reason);
28
+ console.error('Promise:', promise);
29
+ process.exit(1);
30
+ });
31
+
32
+ // Check registration
33
+ if (!isRegistered()) {
34
+ console.error('Device not registered. Run "cmdctrl-cursor-ide register" first.');
35
+ process.exit(1);
36
+ }
37
+
38
+ // Check if already running
39
+ if (isDaemonRunning()) {
40
+ console.error('Daemon is already running. Run "cmdctrl-cursor-ide stop" first.');
41
+ process.exit(1);
42
+ }
43
+
44
+ const config = readConfig()!;
45
+ const credentials = readCredentials()!;
46
+
47
+ console.log(`Starting CmdCtrl Cursor IDE daemon...`);
48
+ console.log(`Server: ${config.serverUrl}`);
49
+ console.log(`Device: ${config.deviceName} (${config.deviceId})`);
50
+
51
+ // Check if Cursor database exists
52
+ if (!cursorDbExists()) {
53
+ console.warn('\nWarning: Cursor database not found.');
54
+ console.warn('Make sure Cursor has been run at least once.');
55
+ }
56
+
57
+ // Check CDP availability
58
+ const cdp = getCDPClient();
59
+ const cdpAvailable = await cdp.isAvailable();
60
+ if (!cdpAvailable) {
61
+ console.warn('\nWarning: Cursor CDP not available.');
62
+ console.warn('To enable message injection, start Cursor with:');
63
+ console.warn(' /Applications/Cursor.app/Contents/MacOS/Cursor --remote-debugging-port=9222');
64
+ console.warn('\nContinuing with observation-only mode...');
65
+ } else {
66
+ console.log('\nCDP: Connected to Cursor');
67
+ const title = await cdp.getWindowTitle();
68
+ if (title) {
69
+ console.log(` Active project: ${title}`);
70
+ }
71
+ }
72
+
73
+ // Write PID file
74
+ writePidFile(process.pid);
75
+
76
+ // Create and start client
77
+ const client = new DaemonClient(config, credentials);
78
+
79
+ // Handle shutdown signals
80
+ const shutdown = async () => {
81
+ console.log('\nShutting down...');
82
+ await client.disconnect();
83
+ process.exit(0);
84
+ };
85
+
86
+ process.on('SIGINT', shutdown);
87
+ process.on('SIGTERM', shutdown);
88
+
89
+ // Connect and run
90
+ try {
91
+ await client.connect();
92
+ console.log('Connected to CmdCtrl server.');
93
+
94
+ if (options.foreground) {
95
+ console.log('Running in foreground. Press Ctrl+C to stop.\n');
96
+ }
97
+
98
+ // Keep process alive
99
+ await new Promise(() => {
100
+ // Never resolves - daemon runs until killed
101
+ });
102
+ } catch (err) {
103
+ console.error('Failed to start daemon:', err);
104
+ process.exit(1);
105
+ }
106
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ readConfig,
3
+ readCredentials,
4
+ isRegistered,
5
+ isDaemonRunning,
6
+ readPidFile,
7
+ cursorDbExists,
8
+ } from '../config/config';
9
+ import { getCDPClient } from '../adapter/cdp-client';
10
+ import { getCursorDB } from '../adapter/cursor-db';
11
+
12
+ /**
13
+ * Status command - check daemon and CDP connection status
14
+ */
15
+ export async function status(): Promise<void> {
16
+ console.log('CmdCtrl Cursor IDE Daemon Status\n');
17
+
18
+ // Registration status
19
+ if (!isRegistered()) {
20
+ console.log('Registration: Not registered');
21
+ console.log('\nRun "cmdctrl-cursor-ide register" to register this device.');
22
+ return;
23
+ }
24
+
25
+ const config = readConfig()!;
26
+ const credentials = readCredentials()!;
27
+
28
+ console.log('Registration: Registered');
29
+ console.log(` Server: ${config.serverUrl}`);
30
+ console.log(` Device: ${config.deviceName}`);
31
+ console.log(` Device ID: ${config.deviceId}`);
32
+
33
+ // Token status
34
+ const tokenExpired = credentials.expiresAt < Date.now();
35
+ console.log(`\nToken: ${tokenExpired ? 'Expired' : 'Valid'}`);
36
+ if (!tokenExpired) {
37
+ const expiresIn = Math.round((credentials.expiresAt - Date.now()) / 1000 / 60);
38
+ console.log(` Expires in: ${expiresIn} minutes`);
39
+ }
40
+
41
+ // Daemon status
42
+ const running = isDaemonRunning();
43
+ console.log(`\nDaemon: ${running ? 'Running' : 'Stopped'}`);
44
+ if (running) {
45
+ const pid = readPidFile();
46
+ console.log(` PID: ${pid}`);
47
+ }
48
+
49
+ // Cursor database status
50
+ const dbExists = cursorDbExists();
51
+ console.log(`\nCursor Database: ${dbExists ? 'Found' : 'Not found'}`);
52
+ if (dbExists) {
53
+ try {
54
+ const db = getCursorDB();
55
+ const composers = db.getComposers();
56
+ console.log(` Sessions: ${composers.length}`);
57
+ } catch (err) {
58
+ console.log(` Error reading database: ${(err as Error).message}`);
59
+ }
60
+ }
61
+
62
+ // CDP status
63
+ console.log('\nCDP (Chrome DevTools Protocol):');
64
+ try {
65
+ const cdp = getCDPClient();
66
+ const available = await cdp.isAvailable();
67
+ if (available) {
68
+ console.log(' Status: Available');
69
+ const title = await cdp.getWindowTitle();
70
+ if (title) {
71
+ console.log(` Active project: ${title}`);
72
+ }
73
+ const composerOpen = await cdp.isComposerOpen();
74
+ console.log(` Composer panel: ${composerOpen ? 'Open' : 'Closed'}`);
75
+ } else {
76
+ console.log(' Status: Not available');
77
+ console.log(' To enable, start Cursor with:');
78
+ console.log(' /Applications/Cursor.app/Contents/MacOS/Cursor --remote-debugging-port=9222');
79
+ }
80
+ } catch (err) {
81
+ console.log(` Status: Error - ${(err as Error).message}`);
82
+ }
83
+ }
@@ -0,0 +1,58 @@
1
+ import { isDaemonRunning, readPidFile, deletePidFile } from '../config/config';
2
+
3
+ /**
4
+ * Stop command - stop the running daemon
5
+ */
6
+ export async function stop(): Promise<void> {
7
+ if (!isDaemonRunning()) {
8
+ console.log('Daemon is not running.');
9
+ return;
10
+ }
11
+
12
+ const pid = readPidFile();
13
+ if (pid === null) {
14
+ console.log('No PID file found.');
15
+ return;
16
+ }
17
+
18
+ console.log(`Stopping daemon (PID ${pid})...`);
19
+
20
+ try {
21
+ // Send SIGTERM for graceful shutdown
22
+ process.kill(pid, 'SIGTERM');
23
+
24
+ // Wait for process to exit (up to 5 seconds)
25
+ let attempts = 0;
26
+ while (attempts < 50) {
27
+ await new Promise((resolve) => setTimeout(resolve, 100));
28
+ try {
29
+ process.kill(pid, 0); // Check if still running
30
+ attempts++;
31
+ } catch {
32
+ // Process has exited
33
+ break;
34
+ }
35
+ }
36
+
37
+ // If still running after 5 seconds, force kill
38
+ try {
39
+ process.kill(pid, 0);
40
+ console.log('Daemon did not stop gracefully, sending SIGKILL...');
41
+ process.kill(pid, 'SIGKILL');
42
+ } catch {
43
+ // Already dead, good
44
+ }
45
+
46
+ deletePidFile();
47
+ console.log('Daemon stopped.');
48
+ } catch (err) {
49
+ if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
50
+ // Process doesn't exist
51
+ deletePidFile();
52
+ console.log('Daemon was not running (stale PID file removed).');
53
+ } else {
54
+ console.error('Failed to stop daemon:', err);
55
+ process.exit(1);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,167 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ export interface CmdCtrlConfig {
6
+ serverUrl: string;
7
+ deviceId: string;
8
+ deviceName: string;
9
+ }
10
+
11
+ export interface Credentials {
12
+ accessToken: string;
13
+ refreshToken: string;
14
+ expiresAt: number; // Unix timestamp
15
+ }
16
+
17
+ const CONFIG_DIR = path.join(os.homedir(), '.cmdctrl-cursor-ide');
18
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
19
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials');
20
+ const PID_FILE = path.join(CONFIG_DIR, 'daemon.pid');
21
+
22
+ // Cursor data paths (macOS)
23
+ export const CURSOR_GLOBAL_STORAGE = path.join(
24
+ os.homedir(),
25
+ 'Library/Application Support/Cursor/User/globalStorage/state.vscdb'
26
+ );
27
+ export const CURSOR_WORKSPACE_STORAGE = path.join(
28
+ os.homedir(),
29
+ 'Library/Application Support/Cursor/User/workspaceStorage'
30
+ );
31
+
32
+ // CDP default port
33
+ export const CDP_PORT = 9222;
34
+ export const CDP_URL = `http://localhost:${CDP_PORT}`;
35
+
36
+ /**
37
+ * Ensure the config directory exists with proper permissions
38
+ */
39
+ export function ensureConfigDir(): void {
40
+ if (!fs.existsSync(CONFIG_DIR)) {
41
+ fs.mkdirSync(CONFIG_DIR, { mode: 0o700 });
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Read the config file
47
+ */
48
+ export function readConfig(): CmdCtrlConfig | null {
49
+ try {
50
+ if (!fs.existsSync(CONFIG_FILE)) {
51
+ return null;
52
+ }
53
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
54
+ return JSON.parse(content) as CmdCtrlConfig;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Write the config file
62
+ */
63
+ export function writeConfig(config: CmdCtrlConfig): void {
64
+ ensureConfigDir();
65
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
66
+ }
67
+
68
+ /**
69
+ * Read credentials (access/refresh tokens)
70
+ */
71
+ export function readCredentials(): Credentials | null {
72
+ try {
73
+ if (!fs.existsSync(CREDENTIALS_FILE)) {
74
+ return null;
75
+ }
76
+ const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
77
+ return JSON.parse(content) as Credentials;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Write credentials with restrictive permissions (600)
85
+ */
86
+ export function writeCredentials(creds: Credentials): void {
87
+ ensureConfigDir();
88
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
89
+ }
90
+
91
+ /**
92
+ * Delete credentials (for logout/revoke)
93
+ */
94
+ export function deleteCredentials(): void {
95
+ if (fs.existsSync(CREDENTIALS_FILE)) {
96
+ fs.unlinkSync(CREDENTIALS_FILE);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Check if device is registered
102
+ */
103
+ export function isRegistered(): boolean {
104
+ const config = readConfig();
105
+ const creds = readCredentials();
106
+ return config !== null && creds !== null && config.deviceId !== '';
107
+ }
108
+
109
+ /**
110
+ * Write daemon PID file
111
+ */
112
+ export function writePidFile(pid: number): void {
113
+ ensureConfigDir();
114
+ fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
115
+ }
116
+
117
+ /**
118
+ * Read daemon PID
119
+ */
120
+ export function readPidFile(): number | null {
121
+ try {
122
+ if (!fs.existsSync(PID_FILE)) {
123
+ return null;
124
+ }
125
+ const content = fs.readFileSync(PID_FILE, 'utf-8');
126
+ return parseInt(content, 10);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Delete PID file
134
+ */
135
+ export function deletePidFile(): void {
136
+ if (fs.existsSync(PID_FILE)) {
137
+ fs.unlinkSync(PID_FILE);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Check if daemon is running
143
+ */
144
+ export function isDaemonRunning(): boolean {
145
+ const pid = readPidFile();
146
+ if (pid === null) {
147
+ return false;
148
+ }
149
+ try {
150
+ // Signal 0 doesn't kill, just checks if process exists
151
+ process.kill(pid, 0);
152
+ return true;
153
+ } catch {
154
+ // Process doesn't exist, clean up stale PID file
155
+ deletePidFile();
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Check if Cursor global storage database exists
162
+ */
163
+ export function cursorDbExists(): boolean {
164
+ return fs.existsSync(CURSOR_GLOBAL_STORAGE);
165
+ }
166
+
167
+ export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE, PID_FILE };
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { register } from './commands/register';
5
+ import { start } from './commands/start';
6
+ import { status } from './commands/status';
7
+ import { stop } from './commands/stop';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('cmdctrl-cursor-ide')
13
+ .description('Cursor IDE daemon - connects Cursor sessions to the CmdCtrl orchestration server')
14
+ .version('0.1.0');
15
+
16
+ program
17
+ .command('register')
18
+ .description('Register this device with a CmdCtrl server')
19
+ .option('-s, --server <url>', 'CmdCtrl server URL', 'http://localhost:4000')
20
+ .option('-n, --name <name>', 'Device name (defaults to hostname-cursor)')
21
+ .action(register);
22
+
23
+ program
24
+ .command('start')
25
+ .description('Start the daemon and connect to the CmdCtrl server')
26
+ .option('-f, --foreground', 'Run in foreground (don\'t daemonize)')
27
+ .action(start);
28
+
29
+ program
30
+ .command('status')
31
+ .description('Check daemon and CDP connection status')
32
+ .action(status);
33
+
34
+ program
35
+ .command('stop')
36
+ .description('Stop the running daemon')
37
+ .action(stop);
38
+
39
+ program.parse();
@@ -0,0 +1,115 @@
1
+ import { getCursorDB, ComposerInfo } from './adapter/cursor-db';
2
+
3
+ export interface ExternalSession {
4
+ session_id: string;
5
+ slug: string;
6
+ title: string;
7
+ project: string;
8
+ project_name: string;
9
+ file_path: string; // For Cursor, this is the DB path
10
+ last_message: string;
11
+ last_activity: string;
12
+ is_active: boolean;
13
+ message_count: number;
14
+ }
15
+
16
+ /**
17
+ * Discover Cursor IDE sessions from the SQLite database
18
+ * Returns sessions in a format compatible with CmdCtrl's external session API
19
+ */
20
+ export function discoverSessions(excludeSessionIds: Set<string> = new Set()): ExternalSession[] {
21
+ const cursorDb = getCursorDB();
22
+ const composers = cursorDb.getComposers();
23
+ const sessions: ExternalSession[] = [];
24
+
25
+ const now = Date.now();
26
+ const ACTIVE_THRESHOLD = 30 * 1000; // 30 seconds
27
+
28
+ for (const composer of composers) {
29
+ // Skip excluded sessions (managed by CmdCtrl)
30
+ if (excludeSessionIds.has(composer.composerId)) {
31
+ continue;
32
+ }
33
+
34
+ // Use composer metadata directly for discovery (avoid expensive per-session queries)
35
+ // The detailed info (message count, last message preview) can be fetched on-demand
36
+ const lastActivity = new Date(composer.lastUpdatedAt).toISOString();
37
+ const isActive = (now - composer.lastUpdatedAt) < ACTIVE_THRESHOLD;
38
+
39
+ // Generate a slug from the composer name
40
+ const slug = generateSlug(composer.name);
41
+
42
+ // Use project path if available, otherwise empty (frontend can show device name)
43
+ const project = composer.projectPath || '';
44
+
45
+ sessions.push({
46
+ session_id: composer.composerId,
47
+ slug,
48
+ title: composer.name || 'Untitled',
49
+ project,
50
+ project_name: project ? project.split('/').pop() || 'cursor' : 'cursor',
51
+ file_path: 'state.vscdb', // Reference to SQLite
52
+ last_message: '', // Fetched on-demand when viewing session
53
+ last_activity: lastActivity,
54
+ is_active: isActive,
55
+ message_count: 0, // Fetched on-demand when viewing session
56
+ });
57
+ }
58
+
59
+ // Sort by last activity (newest first)
60
+ return sessions.sort((a, b) =>
61
+ new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime()
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Generate a URL-friendly slug from a title
67
+ */
68
+ function generateSlug(title: string): string {
69
+ return title
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9\s-]/g, '')
72
+ .replace(/\s+/g, '-')
73
+ .replace(/-+/g, '-')
74
+ .substring(0, 50);
75
+ }
76
+
77
+ /**
78
+ * Get details for a specific session
79
+ */
80
+ export function getSessionDetails(sessionId: string): ExternalSession | null {
81
+ const cursorDb = getCursorDB();
82
+ const composers = cursorDb.getComposers();
83
+ const composer = composers.find(c => c.composerId === sessionId);
84
+
85
+ if (!composer) {
86
+ return null;
87
+ }
88
+
89
+ const messageCount = cursorDb.getBubbleCount(sessionId);
90
+ const latestBubble = cursorDb.getLatestBubble(sessionId);
91
+ const lastMessage = latestBubble?.text?.substring(0, 100) || '';
92
+ const lastActivity = latestBubble?.createdAt || new Date(composer.lastUpdatedAt).toISOString();
93
+
94
+ const now = Date.now();
95
+ const ACTIVE_THRESHOLD = 30 * 1000;
96
+ const lastUpdateTime = latestBubble
97
+ ? new Date(latestBubble.createdAt).getTime()
98
+ : composer.lastUpdatedAt;
99
+ const isActive = (now - lastUpdateTime) < ACTIVE_THRESHOLD;
100
+
101
+ const project = composer.projectPath || '';
102
+
103
+ return {
104
+ session_id: composer.composerId,
105
+ slug: generateSlug(composer.name),
106
+ title: composer.name || 'Untitled',
107
+ project,
108
+ project_name: project ? project.split('/').pop() || 'cursor' : 'cursor',
109
+ file_path: 'state.vscdb',
110
+ last_message: lastMessage,
111
+ last_activity: lastActivity,
112
+ is_active: isActive,
113
+ message_count: messageCount,
114
+ };
115
+ }