@cmdctrl/cursor-cli 0.1.1 → 0.2.1

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 (51) hide show
  1. package/dist/adapter/cursor-cli.d.ts +23 -19
  2. package/dist/adapter/cursor-cli.d.ts.map +1 -1
  3. package/dist/adapter/cursor-cli.js +156 -126
  4. package/dist/adapter/cursor-cli.js.map +1 -1
  5. package/dist/adapter/events.d.ts +36 -20
  6. package/dist/adapter/events.d.ts.map +1 -1
  7. package/dist/adapter/events.js +40 -35
  8. package/dist/adapter/events.js.map +1 -1
  9. package/dist/commands/register.d.ts +0 -3
  10. package/dist/commands/register.d.ts.map +1 -1
  11. package/dist/commands/register.js +23 -122
  12. package/dist/commands/register.js.map +1 -1
  13. package/dist/commands/start.d.ts +1 -8
  14. package/dist/commands/start.d.ts.map +1 -1
  15. package/dist/commands/start.js +117 -30
  16. package/dist/commands/start.js.map +1 -1
  17. package/dist/commands/status.d.ts +1 -4
  18. package/dist/commands/status.d.ts.map +1 -1
  19. package/dist/commands/status.js +25 -22
  20. package/dist/commands/status.js.map +1 -1
  21. package/dist/commands/stop.d.ts +1 -4
  22. package/dist/commands/stop.d.ts.map +1 -1
  23. package/dist/commands/stop.js +21 -26
  24. package/dist/commands/stop.js.map +1 -1
  25. package/dist/commands/unregister.d.ts +2 -0
  26. package/dist/commands/unregister.d.ts.map +1 -0
  27. package/dist/commands/unregister.js +43 -0
  28. package/dist/commands/unregister.js.map +1 -0
  29. package/dist/commands/update.d.ts.map +1 -1
  30. package/dist/commands/update.js +21 -2
  31. package/dist/commands/update.js.map +1 -1
  32. package/dist/index.js +8 -4
  33. package/dist/index.js.map +1 -1
  34. package/dist/message-store.d.ts +18 -0
  35. package/dist/message-store.d.ts.map +1 -0
  36. package/dist/message-store.js +49 -0
  37. package/dist/message-store.js.map +1 -0
  38. package/package.json +2 -2
  39. package/src/adapter/cursor-cli.ts +165 -147
  40. package/src/adapter/events.ts +65 -51
  41. package/src/commands/register.ts +28 -170
  42. package/src/commands/start.ts +132 -41
  43. package/src/commands/status.ts +23 -28
  44. package/src/commands/stop.ts +21 -32
  45. package/src/commands/unregister.ts +43 -0
  46. package/src/commands/update.ts +24 -3
  47. package/src/index.ts +9 -4
  48. package/src/message-store.ts +61 -0
  49. package/src/client/messages.ts +0 -75
  50. package/src/client/websocket.ts +0 -308
  51. package/src/config/config.ts +0 -146
package/src/index.ts CHANGED
@@ -4,12 +4,12 @@ import { Command } from 'commander';
4
4
  import { readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { register } from './commands/register';
7
+ import { unregister } from './commands/unregister';
7
8
  import { start } from './commands/start';
8
9
  import { status } from './commands/status';
9
10
  import { stop } from './commands/stop';
10
11
  import { update } from './commands/update';
11
12
 
12
- // Read version from package.json
13
13
  let version = '0.0.0';
14
14
  try {
15
15
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -22,7 +22,7 @@ const program = new Command();
22
22
 
23
23
  program
24
24
  .name('cmdctrl-cursor-cli')
25
- .description('Cursor CLI daemon - connects your workstation to the CmdCtrl orchestration server')
25
+ .description('CmdCtrl daemon for Cursor CLI')
26
26
  .version(version);
27
27
 
28
28
  program
@@ -32,15 +32,20 @@ program
32
32
  .option('-n, --name <name>', 'Device name (defaults to hostname-cursor)')
33
33
  .action(register);
34
34
 
35
+ program
36
+ .command('unregister')
37
+ .description('Remove local registration data')
38
+ .action(unregister);
39
+
35
40
  program
36
41
  .command('start')
37
42
  .description('Start the daemon and connect to the CmdCtrl server')
38
- .option('-f, --foreground', 'Run in foreground (don\'t daemonize)')
43
+ .option('-f, --foreground', 'Run in foreground (default)')
39
44
  .action(start);
40
45
 
41
46
  program
42
47
  .command('status')
43
- .description('Check daemon connection status')
48
+ .description('Check daemon registration and connection status')
44
49
  .action(status);
45
50
 
46
51
  program
@@ -0,0 +1,61 @@
1
+ /**
2
+ * In-memory message store for Cursor CLI sessions.
3
+ *
4
+ * Cursor CLI manages its own sessions internally, but for CmdCtrl's
5
+ * get_messages protocol we need to track messages ourselves.
6
+ */
7
+
8
+ import { randomUUID } from 'crypto';
9
+ import type { MessageEntry } from '@cmdctrl/daemon-sdk';
10
+
11
+ export class MessageStore {
12
+ private sessions: Map<string, MessageEntry[]> = new Map();
13
+
14
+ storeMessage(sessionId: string, role: 'USER' | 'AGENT' | 'SYSTEM', content: string): string {
15
+ const uuid = randomUUID();
16
+ if (!this.sessions.has(sessionId)) {
17
+ this.sessions.set(sessionId, []);
18
+ }
19
+ this.sessions.get(sessionId)!.push({
20
+ uuid,
21
+ role,
22
+ content,
23
+ timestamp: new Date().toISOString(),
24
+ });
25
+ return uuid;
26
+ }
27
+
28
+ getMessages(
29
+ sessionId: string,
30
+ limit: number,
31
+ beforeUuid?: string,
32
+ afterUuid?: string
33
+ ): {
34
+ messages: MessageEntry[];
35
+ hasMore: boolean;
36
+ oldestUuid?: string;
37
+ newestUuid?: string;
38
+ } {
39
+ let messages = this.sessions.get(sessionId) || [];
40
+
41
+ if (beforeUuid) {
42
+ const idx = messages.findIndex(m => m.uuid === beforeUuid);
43
+ if (idx > 0) messages = messages.slice(0, idx);
44
+ }
45
+
46
+ if (afterUuid) {
47
+ const idx = messages.findIndex(m => m.uuid === afterUuid);
48
+ if (idx >= 0) messages = messages.slice(idx + 1);
49
+ }
50
+
51
+ const hasMore = messages.length > limit;
52
+ const limited = messages.slice(-limit);
53
+
54
+ return {
55
+ messages: limited,
56
+ hasMore,
57
+ oldestUuid: limited[0]?.uuid,
58
+ newestUuid: limited[limited.length - 1]?.uuid,
59
+ };
60
+ }
61
+ }
@@ -1,75 +0,0 @@
1
- /**
2
- * Message types for daemon <-> server communication
3
- */
4
-
5
- // Server -> Daemon messages
6
-
7
- export interface PingMessage {
8
- type: 'ping';
9
- }
10
-
11
- export interface TaskStartMessage {
12
- type: 'task_start';
13
- task_id: string;
14
- instruction: string;
15
- project_path?: string;
16
- }
17
-
18
- export interface TaskResumeMessage {
19
- type: 'task_resume';
20
- task_id: string;
21
- session_id: string;
22
- message: string;
23
- project_path?: string;
24
- }
25
-
26
- export interface TaskCancelMessage {
27
- type: 'task_cancel';
28
- task_id: string;
29
- }
30
-
31
- export interface VersionStatusMessage {
32
- type: 'version_status';
33
- status: 'current' | 'update_available' | 'update_required';
34
- your_version: string;
35
- min_version?: string;
36
- recommended_version?: string;
37
- latest_version?: string;
38
- changelog_url?: string;
39
- message?: string;
40
- }
41
-
42
- export type ServerMessage =
43
- | PingMessage
44
- | TaskStartMessage
45
- | TaskResumeMessage
46
- | TaskCancelMessage
47
- | VersionStatusMessage;
48
-
49
- // Daemon -> Server messages
50
-
51
- export interface PongMessage {
52
- type: 'pong';
53
- }
54
-
55
- export interface StatusMessage {
56
- type: 'status';
57
- running_tasks: string[];
58
- }
59
-
60
- export interface EventMessage {
61
- type: 'event';
62
- task_id: string;
63
- event_type: string;
64
- [key: string]: unknown;
65
- }
66
-
67
- export type DaemonMessage = PongMessage | StatusMessage | EventMessage;
68
-
69
- // Event types sent from daemon to server
70
- export type EventType =
71
- | 'WAIT_FOR_USER'
72
- | 'TASK_COMPLETE'
73
- | 'OUTPUT'
74
- | 'PROGRESS'
75
- | 'ERROR';
@@ -1,308 +0,0 @@
1
- import WebSocket from 'ws';
2
- import { URL } from 'url';
3
- import { CmdCtrlConfig, Credentials } from '../config/config';
4
- import { CursorAdapter } from '../adapter/cursor-cli';
5
- import {
6
- ServerMessage,
7
- DaemonMessage,
8
- TaskStartMessage,
9
- TaskResumeMessage,
10
- TaskCancelMessage,
11
- VersionStatusMessage,
12
- } from './messages';
13
- import { readFileSync } from 'fs';
14
- import { join } from 'path';
15
-
16
- const MAX_RECONNECT_DELAY = 30000; // 30 seconds
17
- const INITIAL_RECONNECT_DELAY = 1000; // 1 second
18
- const PING_INTERVAL = 30000; // 30 seconds
19
-
20
- export class DaemonClient {
21
- private ws: WebSocket | null = null;
22
- private config: CmdCtrlConfig;
23
- private credentials: Credentials;
24
- private reconnectDelay = INITIAL_RECONNECT_DELAY;
25
- private reconnectTimer: NodeJS.Timeout | null = null;
26
- private pingTimer: NodeJS.Timeout | null = null;
27
- private shouldReconnect = true;
28
- private adapter: CursorAdapter;
29
-
30
- constructor(config: CmdCtrlConfig, credentials: Credentials) {
31
- this.config = config;
32
- this.credentials = credentials;
33
- this.adapter = new CursorAdapter(this.sendEvent.bind(this));
34
- }
35
-
36
- /**
37
- * Connect to the CmdCtrl server via WebSocket
38
- */
39
- async connect(): Promise<void> {
40
- return new Promise((resolve, reject) => {
41
- const serverUrl = new URL(this.config.serverUrl);
42
- const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
43
- const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
44
-
45
- console.log(`Connecting to ${wsUrl}...`);
46
-
47
- // Read version from package.json
48
- let daemonVersion = 'unknown';
49
- try {
50
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
51
- daemonVersion = pkg.version;
52
- } catch {
53
- try {
54
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
55
- daemonVersion = pkg.version;
56
- } catch { /* use default */ }
57
- }
58
-
59
- this.ws = new WebSocket(wsUrl, {
60
- headers: {
61
- Authorization: `Bearer ${this.credentials.accessToken}`,
62
- 'X-Device-ID': this.config.deviceId,
63
- 'X-Agent-Type': 'cursor_cli',
64
- 'X-Daemon-Version': daemonVersion,
65
- }
66
- });
67
-
68
- this.ws.on('open', () => {
69
- console.log('WebSocket connected');
70
- this.reconnectDelay = INITIAL_RECONNECT_DELAY;
71
- this.startPingInterval();
72
- this.sendStatus();
73
- resolve();
74
- });
75
-
76
- this.ws.on('message', (data) => {
77
- this.handleMessage(data.toString());
78
- });
79
-
80
- this.ws.on('close', (code, reason) => {
81
- console.log(`WebSocket closed: ${code} ${reason}`);
82
- this.stopPingInterval();
83
- this.scheduleReconnect();
84
- });
85
-
86
- this.ws.on('error', (err) => {
87
- console.error('WebSocket error:', err.message);
88
- if (this.ws?.readyState === WebSocket.CONNECTING) {
89
- reject(err);
90
- }
91
- // For established connections, the 'close' event usually follows 'error',
92
- // but if the connection was killed abruptly, we may not get a clean close.
93
- // Force close the socket to ensure 'close' event fires and triggers reconnect.
94
- if (this.ws?.readyState === WebSocket.OPEN) {
95
- this.ws.terminate();
96
- }
97
- });
98
- });
99
- }
100
-
101
- /**
102
- * Disconnect from server
103
- */
104
- async disconnect(): Promise<void> {
105
- this.shouldReconnect = false;
106
-
107
- if (this.reconnectTimer) {
108
- clearTimeout(this.reconnectTimer);
109
- this.reconnectTimer = null;
110
- }
111
-
112
- this.stopPingInterval();
113
-
114
- // Stop all running tasks
115
- await this.adapter.stopAll();
116
-
117
- if (this.ws) {
118
- this.ws.close(1000, 'Daemon shutting down');
119
- this.ws = null;
120
- }
121
- }
122
-
123
- /**
124
- * Send a message to the server
125
- */
126
- private send(message: DaemonMessage): void {
127
- if (this.ws?.readyState === WebSocket.OPEN) {
128
- this.ws.send(JSON.stringify(message));
129
- }
130
- }
131
-
132
- /**
133
- * Send an event for a task
134
- */
135
- private sendEvent(
136
- taskId: string,
137
- eventType: string,
138
- data: Record<string, unknown>
139
- ): void {
140
- this.send({
141
- type: 'event',
142
- task_id: taskId,
143
- event_type: eventType,
144
- ...data
145
- });
146
- }
147
-
148
- /**
149
- * Send current status to server
150
- */
151
- private sendStatus(): void {
152
- this.send({
153
- type: 'status',
154
- running_tasks: this.adapter.getRunningTasks()
155
- });
156
- }
157
-
158
- /**
159
- * Handle incoming message from server
160
- */
161
- private handleMessage(raw: string): void {
162
- let msg: ServerMessage;
163
- try {
164
- msg = JSON.parse(raw) as ServerMessage;
165
- } catch {
166
- console.error('Failed to parse message:', raw);
167
- return;
168
- }
169
-
170
- switch (msg.type) {
171
- case 'ping':
172
- this.send({ type: 'pong' });
173
- break;
174
-
175
- case 'task_start':
176
- this.handleTaskStart(msg as TaskStartMessage);
177
- break;
178
-
179
- case 'task_resume':
180
- this.handleTaskResume(msg as TaskResumeMessage);
181
- break;
182
-
183
- case 'task_cancel':
184
- this.handleTaskCancel(msg as TaskCancelMessage);
185
- break;
186
-
187
- case 'version_status':
188
- this.handleVersionStatus(msg as VersionStatusMessage);
189
- break;
190
-
191
- default:
192
- console.warn('Unknown message type:', (msg as { type: string }).type);
193
- }
194
- }
195
-
196
- /**
197
- * Handle task_start message
198
- */
199
- private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
200
- console.log(`Starting task ${msg.task_id}: ${msg.instruction.substring(0, 50)}...`);
201
-
202
- try {
203
- await this.adapter.startTask(msg.task_id, msg.instruction, msg.project_path);
204
- } catch (err) {
205
- console.error(`Failed to start task ${msg.task_id}:`, err);
206
- this.sendEvent(msg.task_id, 'ERROR', {
207
- error: (err as Error).message
208
- });
209
- }
210
- }
211
-
212
- /**
213
- * Handle task_resume message
214
- */
215
- private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
216
- console.log(`Resuming task ${msg.task_id} with session ${msg.session_id}`);
217
-
218
- try {
219
- await this.adapter.resumeTask(msg.task_id, msg.session_id, msg.message, msg.project_path);
220
- } catch (err) {
221
- console.error(`Failed to resume task ${msg.task_id}:`, err);
222
- this.sendEvent(msg.task_id, 'ERROR', {
223
- error: (err as Error).message
224
- });
225
- }
226
- }
227
-
228
- /**
229
- * Handle task_cancel message
230
- */
231
- private async handleTaskCancel(msg: TaskCancelMessage): Promise<void> {
232
- console.log(`Cancelling task ${msg.task_id}`);
233
- await this.adapter.cancelTask(msg.task_id);
234
- }
235
-
236
- /**
237
- * Handle version_status message from server
238
- */
239
- private handleVersionStatus(msg: VersionStatusMessage): void {
240
- if (msg.status === 'update_required') {
241
- console.error(`\n✖ Daemon version ${msg.your_version} is no longer supported (minimum: ${msg.min_version})`);
242
- console.error(` Run: cmdctrl-cursor-cli update`);
243
- if (msg.changelog_url) console.error(` Changelog: ${msg.changelog_url}`);
244
- if (msg.message) console.error(` ${msg.message}`);
245
- console.error('');
246
- this.shouldReconnect = false;
247
- process.exit(1);
248
- } else if (msg.status === 'update_available') {
249
- console.warn(`\n⚠ Update available: v${msg.latest_version} (you have v${msg.your_version})`);
250
- console.warn(` Run: cmdctrl-cursor-cli update`);
251
- if (msg.changelog_url) console.warn(` Changelog: ${msg.changelog_url}`);
252
- console.warn('');
253
- }
254
- }
255
-
256
- /**
257
- * Schedule reconnection with exponential backoff
258
- */
259
- private scheduleReconnect(): void {
260
- if (!this.shouldReconnect) {
261
- return;
262
- }
263
-
264
- console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
265
-
266
- this.reconnectTimer = setTimeout(async () => {
267
- try {
268
- await this.connect();
269
- } catch {
270
- // Increase delay with exponential backoff
271
- this.reconnectDelay = Math.min(
272
- this.reconnectDelay * 2,
273
- MAX_RECONNECT_DELAY
274
- );
275
- this.scheduleReconnect();
276
- }
277
- }, this.reconnectDelay);
278
- }
279
-
280
- /**
281
- * Start ping interval to keep connection alive
282
- */
283
- private startPingInterval(): void {
284
- this.pingTimer = setInterval(() => {
285
- if (this.ws?.readyState === WebSocket.OPEN) {
286
- this.ws.ping();
287
- }
288
- }, PING_INTERVAL);
289
- }
290
-
291
- /**
292
- * Stop ping interval
293
- */
294
- private stopPingInterval(): void {
295
- if (this.pingTimer) {
296
- clearInterval(this.pingTimer);
297
- this.pingTimer = null;
298
- }
299
- }
300
-
301
- /**
302
- * Refresh access token using refresh token
303
- */
304
- async refreshToken(): Promise<boolean> {
305
- console.log('Token refresh not yet implemented');
306
- return false;
307
- }
308
- }
@@ -1,146 +0,0 @@
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-cli');
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
- /**
23
- * Ensure the config directory exists with proper permissions
24
- */
25
- export function ensureConfigDir(): void {
26
- if (!fs.existsSync(CONFIG_DIR)) {
27
- fs.mkdirSync(CONFIG_DIR, { mode: 0o700 });
28
- }
29
- }
30
-
31
- /**
32
- * Read the config file
33
- */
34
- export function readConfig(): CmdCtrlConfig | null {
35
- try {
36
- if (!fs.existsSync(CONFIG_FILE)) {
37
- return null;
38
- }
39
- const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
40
- return JSON.parse(content) as CmdCtrlConfig;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- /**
47
- * Write the config file
48
- */
49
- export function writeConfig(config: CmdCtrlConfig): void {
50
- ensureConfigDir();
51
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
52
- }
53
-
54
- /**
55
- * Read credentials (access/refresh tokens)
56
- */
57
- export function readCredentials(): Credentials | null {
58
- try {
59
- if (!fs.existsSync(CREDENTIALS_FILE)) {
60
- return null;
61
- }
62
- const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
63
- return JSON.parse(content) as Credentials;
64
- } catch {
65
- return null;
66
- }
67
- }
68
-
69
- /**
70
- * Write credentials with restrictive permissions (600)
71
- */
72
- export function writeCredentials(creds: Credentials): void {
73
- ensureConfigDir();
74
- fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
75
- }
76
-
77
- /**
78
- * Delete credentials (for logout/revoke)
79
- */
80
- export function deleteCredentials(): void {
81
- if (fs.existsSync(CREDENTIALS_FILE)) {
82
- fs.unlinkSync(CREDENTIALS_FILE);
83
- }
84
- }
85
-
86
- /**
87
- * Check if device is registered
88
- */
89
- export function isRegistered(): boolean {
90
- const config = readConfig();
91
- const creds = readCredentials();
92
- return config !== null && creds !== null && config.deviceId !== '';
93
- }
94
-
95
- /**
96
- * Write daemon PID file
97
- */
98
- export function writePidFile(pid: number): void {
99
- ensureConfigDir();
100
- fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
101
- }
102
-
103
- /**
104
- * Read daemon PID
105
- */
106
- export function readPidFile(): number | null {
107
- try {
108
- if (!fs.existsSync(PID_FILE)) {
109
- return null;
110
- }
111
- const content = fs.readFileSync(PID_FILE, 'utf-8');
112
- return parseInt(content, 10);
113
- } catch {
114
- return null;
115
- }
116
- }
117
-
118
- /**
119
- * Delete PID file
120
- */
121
- export function deletePidFile(): void {
122
- if (fs.existsSync(PID_FILE)) {
123
- fs.unlinkSync(PID_FILE);
124
- }
125
- }
126
-
127
- /**
128
- * Check if daemon is running
129
- */
130
- export function isDaemonRunning(): boolean {
131
- const pid = readPidFile();
132
- if (pid === null) {
133
- return false;
134
- }
135
- try {
136
- // Signal 0 doesn't kill, just checks if process exists
137
- process.kill(pid, 0);
138
- return true;
139
- } catch {
140
- // Process doesn't exist, clean up stale PID file
141
- deletePidFile();
142
- return false;
143
- }
144
- }
145
-
146
- export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE, PID_FILE };