Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,864 changes: 1,650 additions & 214 deletions extensions/cli/package-lock.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion extensions/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@
},
"homepage": "https://continue.dev",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.54",
"@ai-sdk/openai": "^2.0.80",
"@sentry/profiling-node": "^9.43.0",
"ai": "^5.0.109",
"fdir": "^6.4.2",
"find-up": "^8.0.0",
"fzf": "^0.5.2",
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"raindrop-ai": "^0.0.69"
},
"devDependencies": {
"@continuedev/config-yaml": "file:../../packages/config-yaml",
Expand All @@ -59,10 +63,13 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
"@opentelemetry/instrumentation-fs": "^0.23.0",
"@opentelemetry/instrumentation-http": "^0.203.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-node": "^0.203.0",
"@opentelemetry/sdk-trace-base": "^2.0.1",
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.36.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
Expand Down
86 changes: 66 additions & 20 deletions extensions/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { ModelConfig } from "@continuedev/config-yaml";
import { BaseLlmApi } from "@continuedev/openai-adapters";
import chalk from "chalk";
Expand All @@ -14,6 +15,7 @@ import { initializeServices, services } from "../services/index.js";
import { serviceContainer } from "../services/ServiceContainer.js";
import {
AgentFileServiceState,
AuthServiceState,
ModelServiceState,
SERVICE_NAMES,
} from "../services/types.js";
Expand All @@ -24,6 +26,10 @@ import {
} from "../session.js";
import { streamChatResponse } from "../stream/streamChatResponse.js";
import { posthogService } from "../telemetry/posthogService.js";
import {
raindropService,
setupRaindropMetadataFromParams,
} from "../telemetry/raindropService.js";
import { telemetryService } from "../telemetry/telemetryService.js";
import { startTUIChat } from "../ui/index.js";
import { gracefulExit } from "../util/exit.js";
Expand Down Expand Up @@ -221,6 +227,45 @@ function processAndOutputResponse(
}
}

// Helper function to set up Raindrop metadata for headless mode
async function setupRaindropMetadata(): Promise<void> {
const authState = await serviceContainer.get<AuthServiceState>(
SERVICE_NAMES.AUTH,
);
const chatHistoryState = await serviceContainer.get(
SERVICE_NAMES.CHAT_HISTORY,
);

setupRaindropMetadataFromParams(
authState.authConfig?.userId || "anonymous",
(chatHistoryState as any).sessionId,
"cn-headless",
);
}

// Helper function to validate prompt in headless mode
function validateHeadlessPrompt(
userInput: string | undefined,
options: ChatOptions,
): void {
if (!userInput || !userInput.trim()) {
// If resuming or forking, allow empty prompt - just exit successfully after showing history
if (options.resume || options.fork) {
// For resume/fork with no new input, we've already loaded the history
// Just exit successfully (the history was already loaded into chatHistory)
gracefulExit(0);
return;
}

throw new Error(
'Headless mode requires a prompt. Use: cn -p "your prompt"\n' +
'Or pipe input: echo "prompt" | cn -p\n' +
"Or use agent files: cn -p --agent my-org/my-agent\n" +
"Note: Agent files must contain a prompt field.",
);
}
}

// Helper function to handle auto-compaction for headless mode
async function handleAutoCompaction(
chatHistory: ChatHistoryItem[],
Expand Down Expand Up @@ -453,6 +498,9 @@ async function runHeadlessMode(
toolPermissionOverrides: permissionOverrides,
});

// Set Raindrop metadata if enabled
await setupRaindropMetadata();

// Get required services from the service container
const modelState = await serviceContainer.get<ModelServiceState>(
SERVICE_NAMES.MODEL,
Expand Down Expand Up @@ -492,26 +540,8 @@ async function runHeadlessMode(
initialPrompt,
);

// Critical validation: Ensure we have actual prompt text in headless mode
// This prevents the CLI from hanging in TTY-less environments when question() is called
// We check AFTER processing all prompts (including agent files) to ensure we have real content
// EXCEPTION: Allow empty prompts when resuming/forking since they may just want to view history
if (!initialUserInput || !initialUserInput.trim()) {
// If resuming or forking, allow empty prompt - just exit successfully after showing history
if (options.resume || options.fork) {
// For resume/fork with no new input, we've already loaded the history above
// Just exit successfully (the history was already loaded into chatHistory)
await gracefulExit(0);
return;
}

throw new Error(
'Headless mode requires a prompt. Use: cn -p "your prompt"\n' +
'Or pipe input: echo "prompt" | cn -p\n' +
"Or use agent files: cn -p --agent my-org/my-agent\n" +
"Note: Agent files must contain a prompt field.",
);
}
// Validate prompt in headless mode
validateHeadlessPrompt(initialUserInput, options);

let isFirstMessage = true;
while (true) {
Expand Down Expand Up @@ -585,6 +615,22 @@ export async function chat(prompt?: string, options: ChatOptions = {}) {
toolPermissionOverrides: permissionOverrides,
});

// Set Raindrop metadata if enabled
if (raindropService.isEnabled()) {
const authState = await serviceContainer.get<AuthServiceState>(
SERVICE_NAMES.AUTH,
);
const chatHistoryState = await serviceContainer.get(
SERVICE_NAMES.CHAT_HISTORY,
);

raindropService.setMetadata({
userId: authState.authConfig?.userId || "anonymous",
convoId: (chatHistoryState as any).sessionId,
eventName: "cn-chat",
});
}

const agentFileState = await serviceContainer.get<AgentFileServiceState>(
SERVICE_NAMES.AGENT_FILE,
);
Expand Down
38 changes: 30 additions & 8 deletions extensions/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/* eslint-disable max-lines */
import chalk from "chalk";
import type { ChatHistoryItem } from "core/index.js";
import express, { Request, Response } from "express";
import { v4 as uuidv4 } from "uuid";

import { ToolPermissionServiceState } from "src/services/ToolPermissionService.js";
import { posthogService } from "src/telemetry/posthogService.js";
import {
raindropService,
setupRaindropMetadataFromParams,
} from "src/telemetry/raindropService.js";
import { prependPrompt } from "src/util/promptProcessor.js";

import { getAccessToken, getAssistantSlug } from "../auth/workos.js";
Expand Down Expand Up @@ -71,6 +77,19 @@ export function shouldQueueInitialPrompt(
return !hasConversation;
}

/**
* Get the actual prompt from parameter or stdin.
* Checks stdin for piped input if no prompt parameter provided.
*/
function getActualPrompt(prompt?: string): string | undefined {
if (prompt) {
return prompt;
}
// Try to read from stdin (for piped input like: cat file | cn serve)
const stdinInput = readStdinSync();
return stdinInput || undefined;
}

// eslint-disable-next-line max-statements
export async function serve(prompt?: string, options: ServeOptions = {}) {
await posthogService.capture("sessionStart", {});
Expand All @@ -79,14 +98,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
setAgentId(options.id);

// Check if prompt should come from stdin instead of parameter
let actualPrompt = prompt;
if (!prompt) {
// Try to read from stdin (for piped input like: cat file | cn serve)
const stdinInput = readStdinSync();
if (stdinInput) {
actualPrompt = stdinInput;
}
}
const actualPrompt = getActualPrompt(prompt);

const timeoutSeconds = parseInt(options.timeout || "300", 10);
const timeoutMs = timeoutSeconds * 1000;
Expand All @@ -103,6 +115,16 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
headless: true, // Skip onboarding in serve mode
});

// Set Raindrop metadata if enabled
if (raindropService.isEnabled()) {
const authState = await getService<AuthServiceState>(SERVICE_NAMES.AUTH);
setupRaindropMetadataFromParams(
authState.authConfig?.userId || "anonymous",
uuidv4(), // Generate unique ID for serve session
"cn-serve",
);
}

// Get initialized services from the service container
const [configState, modelState, permissionsState, agentFileState] =
await Promise.all([
Expand Down
4 changes: 4 additions & 0 deletions extensions/cli/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { loadAuthConfig } from "../auth/workos.js";
import { initializeWithOnboarding } from "../onboarding.js";
import { raindropService } from "../telemetry/raindropService.js";
import { setBetaUploadArtifactToolEnabled } from "../tools/toolsConfig.js";
import { logger } from "../util/logger.js";

Expand Down Expand Up @@ -58,6 +59,9 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) {

const commandOptions = initOptions.options || {};

// Initialize Raindrop observability early (opt-in via RAINDROP_API_KEY)
await raindropService.initialize();

// Configure beta tools based on command options
if (commandOptions.betaUploadArtifactTool) {
setBetaUploadArtifactToolEnabled(true);
Expand Down
Loading
Loading