-
Notifications
You must be signed in to change notification settings - Fork 4.5k
Expand file tree
/
Copy pathindex.ts
More file actions
476 lines (427 loc) · 15.4 KB
/
index.ts
File metadata and controls
476 lines (427 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
#!/usr/bin/env node
// MUST be the first import - intercepts console/stdout/stderr before any dependencies load
import "./init.js";
import { Command } from "commander";
import { chat } from "./commands/chat.js";
import { checks } from "./commands/checks.js";
import { login } from "./commands/login.js";
import { logout } from "./commands/logout.js";
import { listSessionsCommand } from "./commands/ls.js";
import { remoteTest } from "./commands/remote-test.js";
import { remote } from "./commands/remote.js";
import { review } from "./commands/review.js";
import { serve } from "./commands/serve.js";
import {
handleValidationErrors,
validateFlags,
} from "./flags/flagValidator.js";
import { configureConsoleForHeadless, safeStderr } from "./init.js";
import { sentryService } from "./sentry.js";
import { addCommonOptions, mergeParentOptions } from "./shared-options.js";
import { posthogService } from "./telemetry/posthogService.js";
import { post } from "./util/apiClient.js";
import { markUnhandledError } from "./util/errorState.js";
import { gracefulExit } from "./util/exit.js";
import { logger } from "./util/logger.js";
import { readStdinSync } from "./util/stdin.js";
import { getVersion } from "./version.js";
// TUI lifecycle and two-stage exit state management
let tuiUnmount: (() => void) | null;
let showExitMessage: boolean;
let exitMessageCallback: (() => void) | null;
let lastCtrlCTime: number;
// Agent ID for serve mode - set when serve command is invoked with --id
let agentId: string | undefined;
// Initialize state immediately to avoid temporal dead zone issues with exported functions
(function initializeTUIState() {
tuiUnmount = null;
showExitMessage = false;
exitMessageCallback = null;
lastCtrlCTime = 0;
})();
// Set the agent ID for error reporting (called by serve command)
export function setAgentId(id: string | undefined) {
agentId = id;
}
// Register TUI cleanup function for graceful shutdown
export function setTUIUnmount(unmount: () => void) {
tuiUnmount = unmount;
}
// Register callback to trigger UI updates when exit message state changes
export function setExitMessageCallback(callback: () => void) {
exitMessageCallback = callback;
}
// Sets up SIGINT handler that requires double Ctrl+C within 1 second to exit
export function enableSigintHandler() {
// Remove all existing SIGINT listeners first
process.removeAllListeners("SIGINT");
process.on("SIGINT", async () => {
const now = Date.now();
const timeSinceLastCtrlC = now - lastCtrlCTime;
if (timeSinceLastCtrlC <= 1000 && lastCtrlCTime !== 0) {
// Second Ctrl+C within 1 second - exit
showExitMessage = false;
if (tuiUnmount) {
tuiUnmount();
}
await gracefulExit(0);
} else {
// First Ctrl+C or too much time elapsed - show exit message
lastCtrlCTime = now;
showExitMessage = true;
if (exitMessageCallback) {
exitMessageCallback();
}
// Hide message after 1 second
setTimeout(() => {
showExitMessage = false;
if (exitMessageCallback) {
exitMessageCallback();
}
}, 1000);
}
});
}
// Check if "ctrl+c to exit" message should be displayed
export function shouldShowExitMessage(): boolean {
return showExitMessage;
}
// Helper to report unhandled errors to the API when running in serve mode
async function reportUnhandledErrorToApi(error: Error): Promise<void> {
if (!agentId) {
// Not running in serve mode with an agent ID, skip API reporting
return;
}
try {
await post(`agents/${agentId}/status`, {
status: "FAILED",
errorMessage: `Unhandled error: ${error.message}`,
});
logger.debug(`Reported unhandled error to API for agent ${agentId}`);
} catch (apiError) {
// If API reporting fails, just log it - don't crash
logger.debug(
`Failed to report error to API: ${apiError instanceof Error ? apiError.message : String(apiError)}`,
);
}
}
// Add global error handlers to prevent uncaught errors from crashing the process
process.on("unhandledRejection", (reason, promise) => {
// Mark that an unhandled error occurred - this will cause non-zero exit
markUnhandledError();
// Extract useful information from the reason
const errorDetails = {
promiseString: String(promise),
reasonType: typeof reason,
reasonConstructor: reason?.constructor?.name,
};
// If reason is an Error, use it directly for better stack traces
if (reason instanceof Error) {
logger.error("Unhandled Promise Rejection", reason, errorDetails);
// Report to API if running in serve mode
reportUnhandledErrorToApi(reason).catch(() => {
// Silently fail if API reporting errors - already logged in helper
});
} else {
// Convert non-Error reasons to Error for consistent handling
const error = new Error(`Unhandled rejection: ${String(reason)}`);
logger.error("Unhandled Promise Rejection", error, {
...errorDetails,
originalReason: String(reason),
});
// Report to API if running in serve mode
reportUnhandledErrorToApi(error).catch(() => {
// Silently fail if API reporting errors - already logged in helper
});
}
// Note: Sentry capture is handled by logger.error() above
// Don't exit the process immediately, but hasUnhandledError will cause non-zero exit later
});
process.on("uncaughtException", (error) => {
// Mark that an unhandled error occurred - this will cause non-zero exit
markUnhandledError();
logger.error("Uncaught Exception:", error);
// Report to API if running in serve mode
reportUnhandledErrorToApi(error).catch(() => {
// Silently fail if API reporting errors - already logged in helper
});
// Note: Sentry capture is handled by logger.error() above
// Don't exit the process immediately, but hasUnhandledError will cause non-zero exit later
});
// keyboard interruption handler for non-TUI flows
process.on("SIGINT", async () => {
await gracefulExit(130);
});
const program = new Command();
program
.name("cn")
.description(
"Continue CLI - AI-powered development assistant. Starts an interactive session by default, use -p/--print for non-interactive output.",
)
.version(getVersion(), "-v, --version", "Display version number");
// Root command - chat functionality (default)
// Add common options to the root command
addCommonOptions(program)
.argument("[prompt]", "Optional prompt to send to the assistant")
.option("-p, --print", "Print response and exit (useful for pipes)")
.option(
"--format <format>",
"Output format for headless mode (json). Only works with -p/--print flag.",
)
.option(
"--silent",
"Strip <think></think> tags and excess whitespace from output. Only works with -p/--print flag.",
)
.option("--resume", "Resume from last session")
.option("--fork <sessionId>", "Fork from an existing session ID")
.action(async (prompt, options) => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "cn" });
// Handle piped input - detect it early and decide on mode
let stdinInput = null;
if (!options.print) {
// Check if there's piped input available
stdinInput = readStdinSync();
if (stdinInput) {
// Use piped input as the initial prompt
if (prompt) {
// Combine stdin and prompt argument
prompt = `${stdinInput}\n\n${prompt}`;
} else {
// Only stdin input, use as initial prompt
prompt = stdinInput;
}
// We have piped input but want to use TUI mode
// Store a flag to pass custom stdin to TUI
(options as any).hasPipedInput = true;
}
}
// Configure console overrides FIRST, before any other logging
const isHeadless = options.print;
configureConsoleForHeadless(isHeadless);
logger.configureHeadlessMode(isHeadless);
// Validate all command line flags
const validation = validateFlags({
print: options.print,
format: options.format,
silent: options.silent,
readonly: options.readonly,
auto: options.auto,
config: options.config,
resume: options.resume,
fork: options.fork,
allow: options.allow,
ask: options.ask,
exclude: options.exclude,
isRootCommand: true,
commandName: "cn",
});
if (!validation.isValid) {
handleValidationErrors(validation.errors);
}
if (options.verbose) {
logger.setLevel("debug");
const logPath = logger.getLogPath();
const sessionId = logger.getSessionId();
// In headless mode, suppress these verbose logs
if (!isHeadless) {
console.log(`Verbose logging enabled (session: ${sessionId})`);
console.log(`Logs: ${logPath}`);
console.log(
`Filter this session: grep '\\[${sessionId}\\]' ${logPath}`,
);
}
logger.debug("Verbose logging enabled");
}
// Handle piped input for headless mode (only if we haven't already read it)
if (options.print && !stdinInput) {
const headlessStdinInput = readStdinSync();
if (headlessStdinInput) {
if (prompt) {
// Combine stdin and prompt argument - stdin comes first in XML block
prompt = `<stdin>\n${headlessStdinInput}\n</stdin>\n\n${prompt}`;
} else {
// Only stdin input, use as-is
prompt = headlessStdinInput;
}
}
}
// In headless mode, ensure we have a prompt unless using --agent flag or --resume flag
// Agent files can provide their own prompts, and resume can work without new input
if (options.print && !prompt && !options.agent && !options.resume) {
safeStderr(
"Error: A prompt is required when using the -p/--print flag, unless --prompt, --agent, or --resume is provided.\n\n",
);
safeStderr("Usage examples:\n");
safeStderr(' cn -p "please review my current git diff"\n');
safeStderr(' echo "hello" | cn -p\n');
safeStderr(' cn -p "analyze the code in src/"\n');
safeStderr(" cn -p --agent my-org/my-agent\n");
safeStderr(" cn -p --prompt my-org/my-prompt\n");
safeStderr(" cn -p --resume\n");
await gracefulExit(1);
}
// Map --print to headless mode
options.headless = options.print;
options.print = undefined;
await chat(prompt, options);
});
// Login subcommand
program
.command("login")
.description("Authenticate with Continue")
.action(async () => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "login" });
await login();
});
// Logout subcommand
program
.command("logout")
.description("Log out from Continue")
.action(async () => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "logout" });
await logout();
});
// List sessions subcommand
program
.command("ls")
.description("List recent chat sessions and select one to resume")
.option("--json", "Output in JSON format")
.action(async (options) => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "ls" });
await listSessionsCommand({
format: options.json ? "json" : undefined,
});
});
// Remote subcommand
addCommonOptions(
program
.command("remote [prompt]", { hidden: true })
.description("Launch a remote instance of the cn agent"),
)
.option(
"--url <url>",
"Connect directly to the specified URL instead of creating a new remote environment",
)
.option(
"--id <id>",
"Connect to an existing remote agent by id and establish a tunnel",
)
.option(
"--idempotency-key <key>",
"Idempotency key for session management - allows resuming existing sessions",
)
.option(
"-s, --start",
"Create remote environment and print connection details without starting TUI",
)
.option(
"--branch <branch>",
"Specify the git branch name to use in the remote environment",
)
.option(
"--repo <url>",
"Specify the repository URL to use in the remote environment",
)
.action(async (prompt: string | undefined, options) => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", {
command: "remote",
flagS: options.start,
});
await remote(prompt, options);
});
// Serve subcommand
program
.command("serve [prompt]", { hidden: true })
.description("Start an HTTP server with /state and /message endpoints")
.option(
"--timeout <seconds>",
"Inactivity timeout in seconds (default: 300)",
"300",
)
.option("--port <port>", "Port to run the server on (default: 8000)", "8000")
.option(
"--id <storageId>",
"Upload session snapshots to Continue-managed storage using the provided identifier",
)
.option(
"--beta-upload-artifact-tool",
"Enable beta UploadArtifact tool for uploading screenshots, videos, and logs",
)
.action(async (prompt, options) => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "serve" });
// Merge parent options with subcommand options
const mergedOptions = mergeParentOptions(program, options);
if (mergedOptions.verbose) {
logger.setLevel("debug");
logger.debug("Verbose logging enabled");
}
await serve(prompt, mergedOptions);
});
// Remote test subcommand (for development)
program
.command("remote-test [prompt]")
.description("Test remote TUI mode with a local server")
.option("--url <url>", "Server URL (default: http://localhost:8000)")
.action(async (prompt: string | undefined, options) => {
// Telemetry: record command invocation
await posthogService.capture("cliCommand", { command: "remote-test" });
await remoteTest(prompt, options.url);
});
// Checks subcommand
program
.command("checks [action] [pr-url]")
.description("Show CI check statuses for a PR")
.action(async (action: string | undefined, prUrl: string | undefined) => {
await posthogService.capture("cliCommand", { command: "checks" });
await checks(action, prUrl);
});
// Review subcommand
program
.command("review")
.description("Run AI-powered reviews on your changes")
.option("--base <ref>", "Base git ref to diff against (default: auto-detect)")
.option("--format <format>", "Output format")
.option("--fix", "Automatically apply suggested fixes")
.option("--patch", "Show patches")
.option("--fail-fast", "Stop on first failure")
.option("--review-agents <agents...>", "Specific review agents to run")
.option("--verbose", "Enable verbose logging")
.action(async (options) => {
await posthogService.capture("cliCommand", { command: "review" });
await review(options);
});
// Handle unknown commands
program.on("command:*", () => {
console.error(`Error: Unknown command '${program.args.join(" ")}'\n`);
program.outputHelp();
void gracefulExit(1);
});
export async function runCli(): Promise<void> {
// Handle internal worker subprocess for cn review
if (process.argv.includes("--internal-review-worker")) {
const { runReviewWorker } = await import(
"./commands/review/reviewWorker.js"
);
await runReviewWorker();
return;
}
// Parse arguments and handle errors
try {
program.parse();
} catch (error) {
console.error(error);
sentryService.captureException(
error instanceof Error ? error : new Error(String(error)),
);
process.exit(1);
}
process.on("SIGTERM", async () => {
await gracefulExit(0);
});
}