@cmdctrl/claude-code 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 (83) hide show
  1. package/dist/adapter/claude-cli.d.ts +41 -0
  2. package/dist/adapter/claude-cli.d.ts.map +1 -0
  3. package/dist/adapter/claude-cli.js +525 -0
  4. package/dist/adapter/claude-cli.js.map +1 -0
  5. package/dist/adapter/events.d.ts +52 -0
  6. package/dist/adapter/events.d.ts.map +1 -0
  7. package/dist/adapter/events.js +134 -0
  8. package/dist/adapter/events.js.map +1 -0
  9. package/dist/client/messages.d.ts +140 -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 +115 -0
  14. package/dist/client/websocket.d.ts.map +1 -0
  15. package/dist/client/websocket.js +434 -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 +54 -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 +38 -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/commands/unregister.d.ts +5 -0
  34. package/dist/commands/unregister.d.ts.map +1 -0
  35. package/dist/commands/unregister.js +28 -0
  36. package/dist/commands/unregister.js.map +1 -0
  37. package/dist/config/config.d.ts +68 -0
  38. package/dist/config/config.d.ts.map +1 -0
  39. package/dist/config/config.js +193 -0
  40. package/dist/config/config.js.map +1 -0
  41. package/dist/handlers/context-handler.d.ts +37 -0
  42. package/dist/handlers/context-handler.d.ts.map +1 -0
  43. package/dist/handlers/context-handler.js +303 -0
  44. package/dist/handlers/context-handler.js.map +1 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +39 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/message-reader.d.ts +25 -0
  50. package/dist/message-reader.d.ts.map +1 -0
  51. package/dist/message-reader.js +454 -0
  52. package/dist/message-reader.js.map +1 -0
  53. package/dist/session-discovery.d.ts +48 -0
  54. package/dist/session-discovery.d.ts.map +1 -0
  55. package/dist/session-discovery.js +496 -0
  56. package/dist/session-discovery.js.map +1 -0
  57. package/dist/session-watcher.d.ts +92 -0
  58. package/dist/session-watcher.d.ts.map +1 -0
  59. package/dist/session-watcher.js +494 -0
  60. package/dist/session-watcher.js.map +1 -0
  61. package/dist/session-watcher.test.d.ts +9 -0
  62. package/dist/session-watcher.test.d.ts.map +1 -0
  63. package/dist/session-watcher.test.js +149 -0
  64. package/dist/session-watcher.test.js.map +1 -0
  65. package/jest.config.js +8 -0
  66. package/package.json +42 -0
  67. package/src/adapter/claude-cli.ts +591 -0
  68. package/src/adapter/events.ts +186 -0
  69. package/src/client/messages.ts +193 -0
  70. package/src/client/websocket.ts +509 -0
  71. package/src/commands/register.ts +201 -0
  72. package/src/commands/start.ts +70 -0
  73. package/src/commands/status.ts +47 -0
  74. package/src/commands/stop.ts +58 -0
  75. package/src/commands/unregister.ts +30 -0
  76. package/src/config/config.ts +163 -0
  77. package/src/handlers/context-handler.ts +337 -0
  78. package/src/index.ts +45 -0
  79. package/src/message-reader.ts +485 -0
  80. package/src/session-discovery.ts +557 -0
  81. package/src/session-watcher.test.ts +141 -0
  82. package/src/session-watcher.ts +560 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,509 @@
1
+ import WebSocket from 'ws';
2
+ import { URL } from 'url';
3
+ import { CmdCtrlConfig, Credentials, writeCredentials } from '../config/config';
4
+ import { ClaudeAdapter } from '../adapter/claude-cli';
5
+ import {
6
+ ServerMessage,
7
+ DaemonMessage,
8
+ TaskStartMessage,
9
+ TaskResumeMessage,
10
+ TaskCancelMessage,
11
+ GetMessagesMessage,
12
+ WatchSessionMessage,
13
+ UnwatchSessionMessage,
14
+ ContextRequestMessage,
15
+ SessionInfo,
16
+ } from './messages';
17
+ import { SessionEvent, CompletionEvent } from '../session-watcher';
18
+ import { discoverSessions, ExternalSession } from '../session-discovery';
19
+ import { readMessages, findSessionFile } from '../message-reader';
20
+ import { SessionWatcher } from '../session-watcher';
21
+ import { buildContextResponse } from '../handlers/context-handler';
22
+ import { SessionActivityMessage } from './messages';
23
+
24
+ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
25
+ const INITIAL_RECONNECT_DELAY = 1000; // 1 second
26
+ const PING_INTERVAL = 30000; // 30 seconds
27
+ const SESSION_REFRESH_INTERVAL = 30000; // 30 seconds
28
+
29
+ export class DaemonClient {
30
+ private ws: WebSocket | null = null;
31
+ private config: CmdCtrlConfig;
32
+ private credentials: Credentials;
33
+ private reconnectDelay = INITIAL_RECONNECT_DELAY;
34
+ private reconnectTimer: NodeJS.Timeout | null = null;
35
+ private pingTimer: NodeJS.Timeout | null = null;
36
+ private sessionRefreshTimer: NodeJS.Timeout | null = null;
37
+ private shouldReconnect = true;
38
+ private adapter: ClaudeAdapter;
39
+ private managedSessionIds: Set<string> = new Set(); // Sessions managed by this daemon
40
+ private lastReportedSessionCount = -1; // Track for change detection
41
+ private sessionWatcher: SessionWatcher;
42
+
43
+ constructor(config: CmdCtrlConfig, credentials: Credentials) {
44
+ this.config = config;
45
+ this.credentials = credentials;
46
+ this.adapter = new ClaudeAdapter(this.sendEvent.bind(this));
47
+ this.sessionWatcher = new SessionWatcher(
48
+ this.handleSessionEvent.bind(this),
49
+ this.handleSessionCompletion.bind(this)
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Handle session events from the JSONL watcher and forward to server
55
+ * Converts SessionEvent to EventMessage format
56
+ */
57
+ private handleSessionEvent(event: SessionEvent): void {
58
+ // Send as an event message to the server
59
+ this.send({
60
+ type: 'event',
61
+ task_id: '', // No task_id for watched session events (file-based, not daemon-spawned)
62
+ event_type: event.type,
63
+ session_id: event.sessionId,
64
+ uuid: event.uuid,
65
+ content: event.content,
66
+ timestamp: event.timestamp,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Handle session completion events from the JSONL watcher
72
+ * Sends session_activity message with is_completion=true to trigger push notifications
73
+ */
74
+ private handleSessionCompletion(event: CompletionEvent): void {
75
+ console.log(`[WS] Sending session_activity completion for session ${event.sessionId.slice(-8)}`);
76
+
77
+ const message: SessionActivityMessage = {
78
+ type: 'session_activity',
79
+ session_id: event.sessionId,
80
+ file_path: event.filePath,
81
+ last_message: event.lastMessage,
82
+ message_count: event.messageCount,
83
+ is_completion: true,
84
+ last_activity: new Date().toISOString(),
85
+ };
86
+
87
+ this.send(message);
88
+ }
89
+
90
+ /**
91
+ * Connect to the CmdCtrl server via WebSocket
92
+ */
93
+ async connect(): Promise<void> {
94
+ return new Promise((resolve, reject) => {
95
+ const serverUrl = new URL(this.config.serverUrl);
96
+ const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
97
+ const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
98
+
99
+ console.log(`Connecting to ${wsUrl}...`);
100
+
101
+ this.ws = new WebSocket(wsUrl, {
102
+ headers: {
103
+ Authorization: `Bearer ${this.credentials.refreshToken}`,
104
+ 'X-Device-ID': this.config.deviceId
105
+ }
106
+ });
107
+
108
+ this.ws.on('open', () => {
109
+ console.log('WebSocket connected');
110
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
111
+ this.startPingInterval();
112
+ this.startSessionRefreshInterval();
113
+ this.sendStatus();
114
+ this.reportSessions();
115
+ resolve();
116
+ });
117
+
118
+ this.ws.on('message', (data) => {
119
+ this.handleMessage(data.toString());
120
+ });
121
+
122
+ this.ws.on('close', (code, reason) => {
123
+ console.log(`WebSocket closed: ${code} ${reason}`);
124
+ this.stopPingInterval();
125
+ this.stopSessionRefreshInterval();
126
+ this.scheduleReconnect();
127
+ });
128
+
129
+ this.ws.on('error', (err) => {
130
+ console.error('WebSocket error:', err.message);
131
+ // Don't reject on error - let close handler deal with reconnection
132
+ if (this.ws?.readyState === WebSocket.CONNECTING) {
133
+ reject(err);
134
+ }
135
+ // For established connections, the 'close' event usually follows 'error',
136
+ // but if the connection was killed abruptly, we may not get a clean close.
137
+ // Force close the socket to ensure 'close' event fires and triggers reconnect.
138
+ if (this.ws?.readyState === WebSocket.OPEN) {
139
+ this.ws.terminate();
140
+ }
141
+ });
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Disconnect from server
147
+ */
148
+ async disconnect(): Promise<void> {
149
+ this.shouldReconnect = false;
150
+
151
+ if (this.reconnectTimer) {
152
+ clearTimeout(this.reconnectTimer);
153
+ this.reconnectTimer = null;
154
+ }
155
+
156
+ this.stopPingInterval();
157
+ this.stopSessionRefreshInterval();
158
+
159
+ // Stop all running tasks
160
+ await this.adapter.stopAll();
161
+
162
+ // Stop all session watchers
163
+ this.sessionWatcher.unwatchAll();
164
+
165
+ if (this.ws) {
166
+ this.ws.close(1000, 'Daemon shutting down');
167
+ this.ws = null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Send a message to the server
173
+ */
174
+ private send(message: DaemonMessage): void {
175
+ if (this.ws?.readyState === WebSocket.OPEN) {
176
+ const json = JSON.stringify(message);
177
+ // Log all outgoing messages except pong (too noisy)
178
+ if (message.type !== 'pong') {
179
+ console.log(`[WS OUT] ${message.type}:`, json.length > 200 ? json.substring(0, 200) + '...' : json);
180
+ }
181
+ this.ws.send(json);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Send an event for a task
187
+ */
188
+ private sendEvent(
189
+ taskId: string,
190
+ eventType: string,
191
+ data: Record<string, unknown>
192
+ ): void {
193
+ // Auto-watch session file when we learn the session_id
194
+ // This enables unified notification path via session_activity
195
+ const sessionId = data.session_id as string | undefined;
196
+ if (sessionId) {
197
+ const filePath = findSessionFile(sessionId);
198
+ if (filePath) {
199
+ console.log(`[WS] Auto-watching session ${sessionId} for unified notifications`);
200
+ this.sessionWatcher.watchSession(sessionId, filePath);
201
+ }
202
+ }
203
+
204
+ this.send({
205
+ type: 'event',
206
+ task_id: taskId,
207
+ event_type: eventType,
208
+ ...data
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Send current status to server
214
+ */
215
+ private sendStatus(): void {
216
+ this.send({
217
+ type: 'status',
218
+ running_tasks: this.adapter.getRunningTasks()
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Handle incoming message from server
224
+ */
225
+ private handleMessage(raw: string): void {
226
+ let msg: ServerMessage;
227
+ try {
228
+ msg = JSON.parse(raw) as ServerMessage;
229
+ } catch {
230
+ console.error('Failed to parse message:', raw);
231
+ return;
232
+ }
233
+
234
+ // Log all incoming messages except ping (too noisy)
235
+ if (msg.type !== 'ping') {
236
+ console.log(`[WS IN] ${msg.type}:`, raw.length > 200 ? raw.substring(0, 200) + '...' : raw);
237
+ }
238
+
239
+ switch (msg.type) {
240
+ case 'ping':
241
+ this.send({ type: 'pong' });
242
+ break;
243
+
244
+ case 'task_start':
245
+ this.handleTaskStart(msg as TaskStartMessage);
246
+ break;
247
+
248
+ case 'task_resume':
249
+ this.handleTaskResume(msg as TaskResumeMessage);
250
+ break;
251
+
252
+ case 'task_cancel':
253
+ this.handleTaskCancel(msg as TaskCancelMessage);
254
+ break;
255
+
256
+ case 'get_messages':
257
+ this.handleGetMessages(msg as GetMessagesMessage);
258
+ break;
259
+
260
+ case 'watch_session':
261
+ this.handleWatchSession(msg as WatchSessionMessage);
262
+ break;
263
+
264
+ case 'unwatch_session':
265
+ this.handleUnwatchSession(msg as UnwatchSessionMessage);
266
+ break;
267
+
268
+ case 'context_request':
269
+ this.handleContextRequest(msg as ContextRequestMessage);
270
+ break;
271
+
272
+ default:
273
+ console.warn('Unknown message type:', (msg as { type: string }).type);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Handle task_start message
279
+ */
280
+ private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
281
+ console.log(`Starting task ${msg.task_id}: ${msg.instruction.substring(0, 50)}...`);
282
+
283
+ try {
284
+ await this.adapter.startTask(msg.task_id, msg.instruction, msg.project_path);
285
+ } catch (err) {
286
+ console.error(`Failed to start task ${msg.task_id}:`, err);
287
+ this.sendEvent(msg.task_id, 'ERROR', {
288
+ error: (err as Error).message
289
+ });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Handle task_resume message
295
+ */
296
+ private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
297
+ console.log(`Resuming task ${msg.task_id} with session ${msg.session_id}`);
298
+
299
+ try {
300
+ await this.adapter.resumeTask(msg.task_id, msg.session_id, msg.message, msg.project_path);
301
+ } catch (err) {
302
+ console.error(`Failed to resume task ${msg.task_id}:`, err);
303
+ this.sendEvent(msg.task_id, 'ERROR', {
304
+ error: (err as Error).message
305
+ });
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Handle task_cancel message
311
+ */
312
+ private async handleTaskCancel(msg: TaskCancelMessage): Promise<void> {
313
+ console.log(`Cancelling task ${msg.task_id}`);
314
+ await this.adapter.cancelTask(msg.task_id);
315
+ }
316
+
317
+ /**
318
+ * Handle get_messages request
319
+ */
320
+ private handleGetMessages(msg: GetMessagesMessage): void {
321
+ console.log(`Getting messages for session ${msg.session_id}, limit=${msg.limit}, before=${msg.before_uuid || 'none'}, after=${msg.after_uuid || 'none'}`);
322
+
323
+ try {
324
+ const result = readMessages(msg.session_id, msg.limit, msg.before_uuid, msg.after_uuid);
325
+
326
+ this.send({
327
+ type: 'messages',
328
+ request_id: msg.request_id,
329
+ session_id: msg.session_id,
330
+ messages: result.messages,
331
+ has_more: result.hasMore,
332
+ oldest_uuid: result.oldestUuid,
333
+ newest_uuid: result.newestUuid,
334
+ });
335
+
336
+ console.log(`Sent ${result.messages.length} messages, has_more=${result.hasMore}`);
337
+ } catch (err) {
338
+ console.error(`Failed to get messages for session ${msg.session_id}:`, err);
339
+
340
+ this.send({
341
+ type: 'messages',
342
+ request_id: msg.request_id,
343
+ session_id: msg.session_id,
344
+ messages: [],
345
+ has_more: false,
346
+ error: (err as Error).message,
347
+ });
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Handle watch_session request - start monitoring a session file for changes
353
+ */
354
+ private handleWatchSession(msg: WatchSessionMessage): void {
355
+ console.log(`Starting to watch session ${msg.session_id} at ${msg.file_path}`);
356
+ this.sessionWatcher.watchSession(msg.session_id, msg.file_path);
357
+ }
358
+
359
+ /**
360
+ * Handle unwatch_session request - stop monitoring a session file
361
+ */
362
+ private handleUnwatchSession(msg: UnwatchSessionMessage): void {
363
+ console.log(`Stopping watch for session ${msg.session_id}`);
364
+ this.sessionWatcher.unwatchSession(msg.session_id);
365
+ }
366
+
367
+ /**
368
+ * Handle context_request - extract session context for dashboard summaries
369
+ */
370
+ private handleContextRequest(msg: ContextRequestMessage): void {
371
+ console.log(`Context request for session ${msg.session_id}`);
372
+
373
+ const response = buildContextResponse(msg.request_id, msg.session_id, {
374
+ includeInitialPrompt: msg.include.initial_prompt,
375
+ recentMessagesCount: msg.include.recent_messages,
376
+ includeLastToolUse: msg.include.last_tool_use,
377
+ });
378
+
379
+ this.send(response);
380
+
381
+ if (response.error) {
382
+ console.log(`Context request failed: ${response.error}`);
383
+ } else {
384
+ console.log(`Sent context for session ${msg.session_id}: status=${response.context.status}, messages=${response.context.message_count}`);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Schedule reconnection with exponential backoff
390
+ */
391
+ private scheduleReconnect(): void {
392
+ if (!this.shouldReconnect) {
393
+ return;
394
+ }
395
+
396
+ console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
397
+
398
+ this.reconnectTimer = setTimeout(async () => {
399
+ try {
400
+ await this.connect();
401
+ } catch {
402
+ // Increase delay with exponential backoff
403
+ this.reconnectDelay = Math.min(
404
+ this.reconnectDelay * 2,
405
+ MAX_RECONNECT_DELAY
406
+ );
407
+ this.scheduleReconnect();
408
+ }
409
+ }, this.reconnectDelay);
410
+ }
411
+
412
+ /**
413
+ * Start ping interval to keep connection alive
414
+ */
415
+ private startPingInterval(): void {
416
+ this.pingTimer = setInterval(() => {
417
+ if (this.ws?.readyState === WebSocket.OPEN) {
418
+ this.ws.ping();
419
+ }
420
+ }, PING_INTERVAL);
421
+ }
422
+
423
+ /**
424
+ * Stop ping interval
425
+ */
426
+ private stopPingInterval(): void {
427
+ if (this.pingTimer) {
428
+ clearInterval(this.pingTimer);
429
+ this.pingTimer = null;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Report discovered external sessions to server
435
+ */
436
+ private async reportSessions(): Promise<void> {
437
+ try {
438
+ // Discover sessions, excluding any we're currently managing
439
+ const sessions = await discoverSessions(this.managedSessionIds);
440
+
441
+ // Convert to SessionInfo format
442
+ const sessionInfos: SessionInfo[] = sessions.map((s: ExternalSession) => ({
443
+ session_id: s.session_id,
444
+ slug: s.slug,
445
+ title: s.title,
446
+ project: s.project,
447
+ project_name: s.project_name,
448
+ file_path: s.file_path,
449
+ last_message: s.last_message,
450
+ last_activity: s.last_activity,
451
+ is_active: s.is_active,
452
+ message_count: s.message_count
453
+ }));
454
+
455
+ this.send({
456
+ type: 'report_sessions',
457
+ sessions: sessionInfos
458
+ });
459
+
460
+ console.log(`Reported ${sessionInfos.length} external sessions`);
461
+ } catch (err) {
462
+ console.error('Failed to report sessions:', err);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Start periodic session refresh
468
+ */
469
+ private startSessionRefreshInterval(): void {
470
+ this.sessionRefreshTimer = setInterval(() => {
471
+ this.reportSessions();
472
+ }, SESSION_REFRESH_INTERVAL);
473
+ }
474
+
475
+ /**
476
+ * Stop session refresh interval
477
+ */
478
+ private stopSessionRefreshInterval(): void {
479
+ if (this.sessionRefreshTimer) {
480
+ clearInterval(this.sessionRefreshTimer);
481
+ this.sessionRefreshTimer = null;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Add a session ID to the managed set (sessions started via this daemon)
487
+ */
488
+ addManagedSession(sessionId: string): void {
489
+ this.managedSessionIds.add(sessionId);
490
+ }
491
+
492
+ /**
493
+ * Remove a session ID from the managed set
494
+ */
495
+ removeManagedSession(sessionId: string): void {
496
+ this.managedSessionIds.delete(sessionId);
497
+ }
498
+
499
+ /**
500
+ * Refresh access token using refresh token
501
+ */
502
+ async refreshToken(): Promise<boolean> {
503
+ // TODO: Implement token refresh
504
+ // POST to server with refresh token, get new access token
505
+ // Update credentials file
506
+ console.log('Token refresh not yet implemented');
507
+ return false;
508
+ }
509
+ }
@@ -0,0 +1,201 @@
1
+ import * as os from 'os';
2
+ import * as http from 'http';
3
+ import * as https from 'https';
4
+ import { URL } from 'url';
5
+ import {
6
+ writeConfig,
7
+ writeCredentials,
8
+ readConfig,
9
+ isRegistered,
10
+ CmdCtrlConfig,
11
+ Credentials
12
+ } from '../config/config';
13
+
14
+ interface RegisterOptions {
15
+ server: string;
16
+ name?: string;
17
+ }
18
+
19
+ interface DeviceCodeResponse {
20
+ deviceCode: string;
21
+ userCode: string;
22
+ verificationUrl: string;
23
+ expiresIn: number;
24
+ interval: number;
25
+ }
26
+
27
+ interface TokenResponse {
28
+ accessToken: string;
29
+ refreshToken: string;
30
+ expiresIn: number;
31
+ deviceId: string;
32
+ }
33
+
34
+ /**
35
+ * Make an HTTP(S) request
36
+ */
37
+ function request(
38
+ url: string,
39
+ method: string,
40
+ body?: object
41
+ ): Promise<{ status: number; data: unknown }> {
42
+ return new Promise((resolve, reject) => {
43
+ const parsed = new URL(url);
44
+ const client = parsed.protocol === 'https:' ? https : http;
45
+
46
+ const options = {
47
+ hostname: parsed.hostname,
48
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
49
+ path: parsed.pathname + parsed.search,
50
+ method,
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ Accept: 'application/json'
54
+ }
55
+ };
56
+
57
+ const req = client.request(options, (res) => {
58
+ let data = '';
59
+ res.on('data', (chunk) => (data += chunk));
60
+ res.on('end', () => {
61
+ try {
62
+ const parsed = data ? JSON.parse(data) : {};
63
+ resolve({ status: res.statusCode || 0, data: parsed });
64
+ } catch {
65
+ resolve({ status: res.statusCode || 0, data: {} });
66
+ }
67
+ });
68
+ });
69
+
70
+ req.on('error', reject);
71
+
72
+ if (body) {
73
+ req.write(JSON.stringify(body));
74
+ }
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Poll for token completion (after user verifies in browser)
81
+ */
82
+ async function pollForToken(
83
+ serverUrl: string,
84
+ deviceCode: string,
85
+ interval: number,
86
+ expiresIn: number
87
+ ): Promise<TokenResponse | null> {
88
+ const startTime = Date.now();
89
+ const expiresAt = startTime + expiresIn * 1000;
90
+
91
+ while (Date.now() < expiresAt) {
92
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
93
+
94
+ try {
95
+ const response = await request(`${serverUrl}/api/devices/token`, 'POST', {
96
+ deviceCode
97
+ });
98
+
99
+ if (response.status === 200) {
100
+ return response.data as TokenResponse;
101
+ }
102
+
103
+ // 400 with "authorization_pending" means keep polling
104
+ const data = response.data as { error?: string };
105
+ if (response.status === 400 && data.error === 'authorization_pending') {
106
+ process.stdout.write('.');
107
+ continue;
108
+ }
109
+
110
+ // Other errors should stop polling
111
+ if (response.status >= 400) {
112
+ console.error('\nError polling for token:', data);
113
+ return null;
114
+ }
115
+ } catch (err) {
116
+ console.error('\nError polling for token:', err);
117
+ return null;
118
+ }
119
+ }
120
+
121
+ console.error('\nDevice code expired. Please try again.');
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Register command - implements GitHub CLI style device auth flow
127
+ */
128
+ export async function register(options: RegisterOptions): Promise<void> {
129
+ const serverUrl = options.server.replace(/\/$/, ''); // Remove trailing slash
130
+ const deviceName = options.name || os.hostname();
131
+
132
+ // Check if already registered
133
+ if (isRegistered()) {
134
+ const config = readConfig();
135
+ console.log(`Already registered as "${config?.deviceName}" (${config?.deviceId})`);
136
+ console.log(`Server: ${config?.serverUrl}`);
137
+ console.log('\nTo re-register, delete ~/.cmdctrl-claude-code/config.json and ~/.cmdctrl-claude-code/credentials');
138
+ return;
139
+ }
140
+
141
+ console.log(`Registering device "${deviceName}" with ${serverUrl}...\n`);
142
+
143
+ // Step 1: Request device code
144
+ let codeResponse: DeviceCodeResponse;
145
+ try {
146
+ const response = await request(`${serverUrl}/api/devices/code`, 'POST', {
147
+ deviceName,
148
+ hostname: os.hostname(),
149
+ agentType: 'claude_code',
150
+ });
151
+
152
+ if (response.status !== 200) {
153
+ console.error('Failed to get device code:', response.data);
154
+ process.exit(1);
155
+ }
156
+
157
+ codeResponse = response.data as DeviceCodeResponse;
158
+ } catch (err) {
159
+ console.error('Failed to connect to server:', err);
160
+ process.exit(1);
161
+ }
162
+
163
+ // Step 2: Display instructions to user
164
+ console.log('To complete registration, open this URL in your browser:\n');
165
+ console.log(` ${codeResponse.verificationUrl}\n`);
166
+ console.log('Then enter this code:\n');
167
+ console.log(` ${codeResponse.userCode}\n`);
168
+ console.log('Waiting for verification...');
169
+
170
+ // Step 3: Poll for completion
171
+ const tokenResponse = await pollForToken(
172
+ serverUrl,
173
+ codeResponse.deviceCode,
174
+ codeResponse.interval,
175
+ codeResponse.expiresIn
176
+ );
177
+
178
+ if (!tokenResponse) {
179
+ process.exit(1);
180
+ }
181
+
182
+ // Step 4: Save config and credentials
183
+ const config: CmdCtrlConfig = {
184
+ serverUrl,
185
+ deviceId: tokenResponse.deviceId,
186
+ deviceName
187
+ };
188
+
189
+ const credentials: Credentials = {
190
+ accessToken: tokenResponse.accessToken,
191
+ refreshToken: tokenResponse.refreshToken,
192
+ expiresAt: Date.now() + tokenResponse.expiresIn * 1000
193
+ };
194
+
195
+ writeConfig(config);
196
+ writeCredentials(credentials);
197
+
198
+ console.log('\n\nRegistration complete!');
199
+ console.log(`Device ID: ${tokenResponse.deviceId}`);
200
+ console.log(`\nRun 'cmdctrl-claude-code start' to connect to the server.`);
201
+ }